1use panproto_schema::Protocol;
55use rustc_hash::FxHashSet;
56use serde::{Deserialize, Serialize};
57
58use crate::diff::{ConstraintChange, SchemaDiff};
59
60#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(rename_all = "kebab-case")]
72pub enum Classification {
73 FullyCompatible,
75 BackwardCompatible,
77 #[default]
80 Breaking,
81}
82
83#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
85pub struct CompatReport {
86 pub breaking: Vec<BreakingChange>,
88 pub non_breaking: Vec<NonBreakingChange>,
90 pub compatible: bool,
92 #[serde(default)]
94 pub classification: Classification,
95}
96
97#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
99#[non_exhaustive]
100pub enum BreakingChange {
101 RemovedVertex {
103 vertex_id: String,
105 },
106
107 RemovedEdge {
109 src: String,
111 tgt: String,
113 kind: String,
115 name: Option<String>,
117 },
118
119 RequiredEdgeAdded {
122 vertex_id: String,
124 src: String,
126 tgt: String,
128 kind: String,
130 name: Option<String>,
132 },
133
134 RequiredEdgeRemoved {
137 vertex_id: String,
139 src: String,
141 tgt: String,
143 kind: String,
145 name: Option<String>,
147 },
148
149 KindChanged {
151 vertex_id: String,
153 old_kind: String,
155 new_kind: String,
157 },
158
159 ConstraintTightened {
161 vertex_id: String,
163 sort: String,
165 old_value: String,
167 new_value: String,
169 },
170
171 ConstraintAdded {
173 vertex_id: String,
175 sort: String,
177 value: String,
179 },
180
181 AddedVariant {
185 vertex_id: String,
187 variant_id: String,
189 },
190
191 RemovedVariant {
193 vertex_id: String,
195 variant_id: String,
197 },
198
199 ModifiedVariant {
201 vertex_id: String,
203 variant_id: String,
205 old_tag: Option<String>,
207 new_tag: Option<String>,
209 },
210
211 OrderToUnordered {
213 edge: panproto_schema::Edge,
215 },
216
217 UnorderedToOrdered {
220 edge: panproto_schema::Edge,
222 },
223
224 RecursionPointAdded {
226 mu_id: String,
228 },
229
230 RecursionBroken {
232 mu_id: String,
234 },
235
236 RecursionPointModified {
238 mu_id: String,
240 old_target: String,
242 new_target: String,
244 },
245
246 LinearityTightened {
248 edge: panproto_schema::Edge,
250 old_mode: panproto_schema::UsageMode,
252 new_mode: panproto_schema::UsageMode,
254 },
255
256 NsidChanged {
258 vertex_id: String,
260 old_nsid: String,
262 new_nsid: String,
264 },
265
266 NsidRemoved {
268 vertex_id: String,
270 },
271
272 HyperEdgeRemoved {
274 id: String,
276 },
277
278 HyperEdgeModified {
280 id: String,
282 },
283
284 SpanRemoved {
286 id: String,
288 },
289
290 SpanModified {
292 id: String,
294 },
295
296 NominalFlipped {
298 vertex_id: String,
300 old_value: bool,
302 new_value: bool,
304 },
305
306 EnrichmentRemoved {
308 category: String,
311 key: String,
313 },
314
315 EnrichmentModified {
317 category: String,
319 key: String,
321 },
322
323 CoercionClassDowngraded {
325 from_kind: String,
327 to_kind: String,
329 old_class: String,
331 new_class: String,
333 },
334
335 CoercionRemoved {
341 from_kind: String,
343 to_kind: String,
345 },
346
347 RenamedVertex {
350 old_id: String,
352 new_id: String,
354 },
355
356 UnclassifiedChange {
361 category: String,
363 count: usize,
365 },
366}
367
368#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
370#[non_exhaustive]
371pub enum NonBreakingChange {
372 AddedVertex {
374 vertex_id: String,
376 },
377
378 AddedEdge {
380 src: String,
382 tgt: String,
384 kind: String,
386 name: Option<String>,
388 },
389
390 ConstraintRelaxed {
392 vertex_id: String,
394 sort: String,
396 old_value: String,
398 new_value: String,
400 },
401
402 ConstraintRemoved {
404 vertex_id: String,
406 sort: String,
408 },
409
410 RemovedEdge {
413 src: String,
415 tgt: String,
417 kind: String,
419 name: Option<String>,
421 },
422
423 AddedNsid {
425 vertex_id: String,
427 nsid: String,
429 },
430
431 AddedHyperEdge {
433 id: String,
435 },
436
437 AddedSpan {
439 id: String,
441 },
442
443 EnrichmentAdded {
445 category: String,
447 key: String,
449 },
450
451 LinearityRelaxed {
453 edge: panproto_schema::Edge,
455 old_mode: panproto_schema::UsageMode,
457 new_mode: panproto_schema::UsageMode,
459 },
460}
461
462#[must_use]
473#[allow(clippy::too_many_lines)]
474pub fn classify(diff: &SchemaDiff, protocol: &Protocol) -> CompatReport {
475 let mut breaking = Vec::new();
476 let mut non_breaking = Vec::new();
477
478 let SchemaDiff {
481 added_vertices,
482 removed_vertices,
483 kind_changes,
484 added_edges,
485 removed_edges,
486 modified_constraints,
487 added_hyper_edges,
488 removed_hyper_edges,
489 modified_hyper_edges,
490 added_required,
491 removed_required,
492 added_nsids,
493 removed_nsids,
494 changed_nsids,
495 added_variants,
496 removed_variants,
497 modified_variants,
498 order_changes,
499 added_recursion_points,
500 removed_recursion_points,
501 modified_recursion_points,
502 usage_mode_changes,
503 added_spans,
504 removed_spans,
505 modified_spans,
506 nominal_changes,
507 added_coercions,
508 removed_coercions,
509 modified_coercions,
510 added_mergers,
511 removed_mergers,
512 modified_mergers,
513 added_defaults,
514 removed_defaults,
515 modified_defaults,
516 added_policies,
517 removed_policies,
518 modified_policies,
519 renamed_vertices,
520 } = diff;
521
522 let mut renamed_old: FxHashSet<&str> = FxHashSet::default();
524 let mut renamed_new: FxHashSet<&str> = FxHashSet::default();
525 for (old_id, new_id) in renamed_vertices {
526 renamed_old.insert(old_id.as_str());
527 renamed_new.insert(new_id.as_str());
528 breaking.push(BreakingChange::RenamedVertex {
529 old_id: old_id.clone(),
530 new_id: new_id.clone(),
531 });
532 }
533
534 let required_added: FxHashSet<&panproto_schema::Edge> =
536 added_required.values().flatten().collect();
537 let required_removed: FxHashSet<&panproto_schema::Edge> =
538 removed_required.values().flatten().collect();
539 for (vid, edges) in added_required {
540 for e in edges {
541 breaking.push(BreakingChange::RequiredEdgeAdded {
542 vertex_id: vid.clone(),
543 src: e.src.to_string(),
544 tgt: e.tgt.to_string(),
545 kind: e.kind.to_string(),
546 name: e.name.as_ref().map(ToString::to_string),
547 });
548 }
549 }
550 for (vid, edges) in removed_required {
551 for e in edges {
552 breaking.push(BreakingChange::RequiredEdgeRemoved {
553 vertex_id: vid.clone(),
554 src: e.src.to_string(),
555 tgt: e.tgt.to_string(),
556 kind: e.kind.to_string(),
557 name: e.name.as_ref().map(ToString::to_string),
558 });
559 }
560 }
561
562 for v in removed_vertices {
564 if renamed_old.contains(v.as_str()) {
565 continue;
566 }
567 breaking.push(BreakingChange::RemovedVertex {
568 vertex_id: v.clone(),
569 });
570 }
571
572 for v in added_vertices {
574 if renamed_new.contains(v.as_str()) {
575 continue;
576 }
577 non_breaking.push(NonBreakingChange::AddedVertex {
578 vertex_id: v.clone(),
579 });
580 }
581
582 for e in removed_edges {
586 if required_removed.contains(e) {
587 continue;
588 }
589 if protocol.find_edge_rule(&e.kind).is_some() {
590 breaking.push(BreakingChange::RemovedEdge {
591 src: e.src.to_string(),
592 tgt: e.tgt.to_string(),
593 kind: e.kind.to_string(),
594 name: e.name.as_ref().map(ToString::to_string),
595 });
596 } else {
597 non_breaking.push(NonBreakingChange::RemovedEdge {
598 src: e.src.to_string(),
599 tgt: e.tgt.to_string(),
600 kind: e.kind.to_string(),
601 name: e.name.as_ref().map(ToString::to_string),
602 });
603 }
604 }
605
606 for e in added_edges {
609 if required_added.contains(e) {
610 continue;
611 }
612 non_breaking.push(NonBreakingChange::AddedEdge {
613 src: e.src.to_string(),
614 tgt: e.tgt.to_string(),
615 kind: e.kind.to_string(),
616 name: e.name.as_ref().map(ToString::to_string),
617 });
618 }
619
620 for kc in kind_changes {
622 breaking.push(BreakingChange::KindChanged {
623 vertex_id: kc.vertex_id.clone(),
624 old_kind: kc.old_kind.clone(),
625 new_kind: kc.new_kind.clone(),
626 });
627 }
628
629 for (vid, cdiff) in modified_constraints {
633 for c in &cdiff.added {
634 breaking.push(BreakingChange::ConstraintAdded {
635 vertex_id: vid.clone(),
636 sort: c.sort.to_string(),
637 value: c.value.clone(),
638 });
639 }
640 for c in &cdiff.removed {
641 non_breaking.push(NonBreakingChange::ConstraintRemoved {
642 vertex_id: vid.clone(),
643 sort: c.sort.to_string(),
644 });
645 }
646 for change in &cdiff.changed {
647 classify_constraint_change(vid, change, &mut breaking, &mut non_breaking);
648 }
649 }
650
651 for v in added_variants {
653 breaking.push(BreakingChange::AddedVariant {
654 vertex_id: v.parent_vertex.to_string(),
655 variant_id: v.id.to_string(),
656 });
657 }
658 for v in removed_variants {
659 breaking.push(BreakingChange::RemovedVariant {
660 vertex_id: v.parent_vertex.to_string(),
661 variant_id: v.id.to_string(),
662 });
663 }
664 for vc in modified_variants {
665 breaking.push(BreakingChange::ModifiedVariant {
666 vertex_id: vc.parent_vertex.clone(),
667 variant_id: vc.id.clone(),
668 old_tag: vc.old_tag.clone(),
669 new_tag: vc.new_tag.clone(),
670 });
671 }
672
673 for (edge, old_pos, new_pos) in order_changes {
675 match (old_pos.is_some(), new_pos.is_some()) {
676 (true, false) => {
677 breaking.push(BreakingChange::OrderToUnordered { edge: edge.clone() });
678 }
679 (false, true) => {
680 breaking.push(BreakingChange::UnorderedToOrdered { edge: edge.clone() });
681 }
682 _ => {
686 breaking.push(BreakingChange::UnclassifiedChange {
687 category: "reordered_edge".to_string(),
688 count: 1,
689 });
690 }
691 }
692 }
693
694 for (mu, _) in added_recursion_points {
696 breaking.push(BreakingChange::RecursionPointAdded {
697 mu_id: mu.to_string(),
698 });
699 }
700 for (mu, _) in removed_recursion_points {
701 breaking.push(BreakingChange::RecursionBroken {
702 mu_id: mu.to_string(),
703 });
704 }
705 for rpc in modified_recursion_points {
706 breaking.push(BreakingChange::RecursionPointModified {
707 mu_id: rpc.mu_id.clone(),
708 old_target: rpc.old_target.clone(),
709 new_target: rpc.new_target.clone(),
710 });
711 }
712
713 for (edge, old_mode, new_mode) in usage_mode_changes {
715 if is_usage_tightened(old_mode, new_mode) {
716 breaking.push(BreakingChange::LinearityTightened {
717 edge: edge.clone(),
718 old_mode: old_mode.clone(),
719 new_mode: new_mode.clone(),
720 });
721 } else {
722 non_breaking.push(NonBreakingChange::LinearityRelaxed {
723 edge: edge.clone(),
724 old_mode: old_mode.clone(),
725 new_mode: new_mode.clone(),
726 });
727 }
728 }
729
730 for (vid, nsid) in added_nsids {
732 non_breaking.push(NonBreakingChange::AddedNsid {
733 vertex_id: vid.clone(),
734 nsid: nsid.clone(),
735 });
736 }
737 for vid in removed_nsids {
738 breaking.push(BreakingChange::NsidRemoved {
739 vertex_id: vid.clone(),
740 });
741 }
742 for (vid, old_nsid, new_nsid) in changed_nsids {
743 breaking.push(BreakingChange::NsidChanged {
744 vertex_id: vid.clone(),
745 old_nsid: old_nsid.clone(),
746 new_nsid: new_nsid.clone(),
747 });
748 }
749
750 for id in added_hyper_edges {
752 non_breaking.push(NonBreakingChange::AddedHyperEdge { id: id.clone() });
753 }
754 for id in removed_hyper_edges {
755 breaking.push(BreakingChange::HyperEdgeRemoved { id: id.clone() });
756 }
757 for hec in modified_hyper_edges {
758 breaking.push(BreakingChange::HyperEdgeModified { id: hec.id.clone() });
759 }
760
761 for id in added_spans {
763 non_breaking.push(NonBreakingChange::AddedSpan { id: id.clone() });
764 }
765 for id in removed_spans {
766 breaking.push(BreakingChange::SpanRemoved { id: id.clone() });
767 }
768 for sc in modified_spans {
769 breaking.push(BreakingChange::SpanModified { id: sc.id.clone() });
770 }
771
772 for (vid, old_val, new_val) in nominal_changes {
774 breaking.push(BreakingChange::NominalFlipped {
775 vertex_id: vid.clone(),
776 old_value: *old_val,
777 new_value: *new_val,
778 });
779 }
780
781 classify_enrichment(
783 "coercion",
784 added_coercions.iter().map(coercion_key),
785 removed_coercions.iter().map(coercion_key),
786 modified_coercions.iter().map(coercion_key),
787 &mut breaking,
788 &mut non_breaking,
789 );
790 classify_enrichment(
791 "merger",
792 added_mergers.iter().cloned(),
793 removed_mergers.iter().cloned(),
794 modified_mergers.iter().cloned(),
795 &mut breaking,
796 &mut non_breaking,
797 );
798 classify_enrichment(
799 "default",
800 added_defaults.iter().cloned(),
801 removed_defaults.iter().cloned(),
802 modified_defaults.iter().cloned(),
803 &mut breaking,
804 &mut non_breaking,
805 );
806 classify_enrichment(
807 "policy",
808 added_policies.iter().cloned(),
809 removed_policies.iter().cloned(),
810 modified_policies.iter().cloned(),
811 &mut breaking,
812 &mut non_breaking,
813 );
814
815 finish_report(breaking, non_breaking)
816}
817
818#[must_use]
826pub fn classify_with_schemas(
827 diff: &SchemaDiff,
828 protocol: &Protocol,
829 old_schema: &panproto_schema::Schema,
830 new_schema: &panproto_schema::Schema,
831) -> CompatReport {
832 let mut report = classify(diff, protocol);
833
834 for (key, new_spec) in &new_schema.coercions {
838 if let Some(old_spec) = old_schema.coercions.get(key) {
839 if new_spec.class > old_spec.class {
840 report
841 .breaking
842 .push(BreakingChange::CoercionClassDowngraded {
843 from_kind: key.0.to_string(),
844 to_kind: key.1.to_string(),
845 old_class: format!("{:?}", old_spec.class),
846 new_class: format!("{:?}", new_spec.class),
847 });
848 }
849 }
850 }
851
852 report.compatible = report.breaking.is_empty();
853 report.classification = classify_verdict(&report.breaking, &report.non_breaking);
854 report
855}
856
857const fn classify_verdict(
859 breaking: &[BreakingChange],
860 non_breaking: &[NonBreakingChange],
861) -> Classification {
862 if !breaking.is_empty() {
863 Classification::Breaking
864 } else if non_breaking.is_empty() {
865 Classification::FullyCompatible
866 } else {
867 Classification::BackwardCompatible
868 }
869}
870
871fn finish_report(
873 breaking: Vec<BreakingChange>,
874 non_breaking: Vec<NonBreakingChange>,
875) -> CompatReport {
876 let compatible = breaking.is_empty();
877 let classification = classify_verdict(&breaking, &non_breaking);
878 CompatReport {
879 breaking,
880 non_breaking,
881 compatible,
882 classification,
883 }
884}
885
886fn coercion_key(key: &(String, String)) -> String {
888 format!("{} -> {}", key.0, key.1)
889}
890
891fn classify_enrichment(
894 category: &str,
895 added: impl IntoIterator<Item = String>,
896 removed: impl IntoIterator<Item = String>,
897 modified: impl IntoIterator<Item = String>,
898 breaking: &mut Vec<BreakingChange>,
899 non_breaking: &mut Vec<NonBreakingChange>,
900) {
901 for key in added {
902 non_breaking.push(NonBreakingChange::EnrichmentAdded {
903 category: category.to_string(),
904 key,
905 });
906 }
907 for key in removed {
908 breaking.push(BreakingChange::EnrichmentRemoved {
909 category: category.to_string(),
910 key,
911 });
912 }
913 for key in modified {
914 breaking.push(BreakingChange::EnrichmentModified {
915 category: category.to_string(),
916 key,
917 });
918 }
919}
920
921const fn is_usage_tightened(
926 old_mode: &panproto_schema::UsageMode,
927 new_mode: &panproto_schema::UsageMode,
928) -> bool {
929 use panproto_schema::UsageMode::{Affine, Linear, Structural};
930 matches!(
931 (old_mode, new_mode),
932 (Structural | Affine, Linear) | (Structural, Affine)
933 )
934}
935
936fn classify_constraint_change(
938 vertex_id: &str,
939 change: &ConstraintChange,
940 breaking: &mut Vec<BreakingChange>,
941 non_breaking: &mut Vec<NonBreakingChange>,
942) {
943 let is_tightened = is_constraint_tightened(&change.sort, &change.old_value, &change.new_value);
944
945 if is_tightened {
946 breaking.push(BreakingChange::ConstraintTightened {
947 vertex_id: vertex_id.to_string(),
948 sort: change.sort.clone(),
949 old_value: change.old_value.clone(),
950 new_value: change.new_value.clone(),
951 });
952 } else {
953 non_breaking.push(NonBreakingChange::ConstraintRelaxed {
954 vertex_id: vertex_id.to_string(),
955 sort: change.sort.clone(),
956 old_value: change.old_value.clone(),
957 new_value: change.new_value.clone(),
958 });
959 }
960}
961
962fn is_constraint_tightened(sort: &str, old_val: &str, new_val: &str) -> bool {
969 match sort {
970 "maxLength" | "maxSize" | "maximum" | "maxGraphemes" => {
971 let old_n: Result<i64, _> = old_val.parse();
972 let new_n: Result<i64, _> = new_val.parse();
973 if let (Ok(o), Ok(n)) = (old_n, new_n) {
974 return n < o;
975 }
976 true
978 }
979 "minLength" | "minimum" => {
980 let old_n: Result<i64, _> = old_val.parse();
981 let new_n: Result<i64, _> = new_val.parse();
982 if let (Ok(o), Ok(n)) = (old_n, new_n) {
983 return n > o;
984 }
985 true
986 }
987 _ => {
988 true
990 }
991 }
992}
993
994#[cfg(test)]
995mod tests {
996 use super::*;
997 use crate::diff::{
998 ConstraintDiff, HyperEdgeChange, KindChange, RecursionPointChange, SpanChange,
999 VariantChange,
1000 };
1001 use panproto_schema::{Constraint, Edge, EdgeRule, RecursionPoint, UsageMode, Variant};
1002 use std::collections::HashMap;
1003
1004 fn test_protocol() -> Protocol {
1005 Protocol {
1006 name: "test".into(),
1007 schema_theory: "ThTest".into(),
1008 instance_theory: "ThWType".into(),
1009 edge_rules: vec![EdgeRule {
1010 edge_kind: "prop".into(),
1011 src_kinds: vec!["object".into()],
1012 tgt_kinds: vec![],
1013 }],
1014 obj_kinds: vec!["object".into()],
1015 constraint_sorts: vec!["maxLength".into()],
1016 ..Protocol::default()
1017 }
1018 }
1019
1020 fn edge(src: &str, tgt: &str, kind: &str, name: Option<&str>) -> Edge {
1021 Edge {
1022 src: src.into(),
1023 tgt: tgt.into(),
1024 kind: kind.into(),
1025 name: name.map(Into::into),
1026 }
1027 }
1028
1029 #[test]
1030 fn classify_removed_required_field_as_breaking() {
1031 let diff = SchemaDiff {
1032 removed_vertices: vec!["body.text".into()],
1033 removed_edges: vec![edge("body", "body.text", "prop", Some("text"))],
1034 ..SchemaDiff::default()
1035 };
1036
1037 let report = classify(&diff, &test_protocol());
1038 assert!(!report.compatible, "removing a vertex should be breaking");
1039 assert_eq!(report.breaking.len(), 2); assert_eq!(report.classification, Classification::Breaking);
1041 }
1042
1043 #[test]
1044 fn classify_added_optional_field_as_non_breaking() {
1045 let diff = SchemaDiff {
1046 added_vertices: vec!["body.newField".into()],
1047 added_edges: vec![edge("body", "body.newField", "prop", Some("newField"))],
1048 ..SchemaDiff::default()
1049 };
1050
1051 let report = classify(&diff, &test_protocol());
1052 assert!(report.compatible, "adding a vertex should be non-breaking");
1053 assert_eq!(report.non_breaking.len(), 2); assert!(report.breaking.is_empty());
1055 assert_eq!(report.classification, Classification::BackwardCompatible);
1056 }
1057
1058 #[test]
1059 fn classify_empty_diff_is_fully_compatible() {
1060 let report = classify(&SchemaDiff::default(), &test_protocol());
1061 assert!(report.compatible);
1062 assert!(report.breaking.is_empty());
1063 assert!(report.non_breaking.is_empty());
1064 assert_eq!(report.classification, Classification::FullyCompatible);
1065 }
1066
1067 #[test]
1072 fn classify_added_required_edge_as_breaking() {
1073 let e = edge("body", "body.text", "prop", Some("text"));
1074 let diff = SchemaDiff {
1075 added_required: HashMap::from([("body".into(), vec![e.clone()])]),
1076 added_edges: vec![e],
1078 ..SchemaDiff::default()
1079 };
1080
1081 let report = classify(&diff, &test_protocol());
1082 assert!(!report.compatible, "adding a required edge is breaking");
1083 assert!(
1084 report
1085 .breaking
1086 .iter()
1087 .any(|b| matches!(b, BreakingChange::RequiredEdgeAdded { .. }))
1088 );
1089 assert!(
1091 !report
1092 .non_breaking
1093 .iter()
1094 .any(|nb| matches!(nb, NonBreakingChange::AddedEdge { .. }))
1095 );
1096 }
1097
1098 #[test]
1099 fn classify_removed_required_edge_as_breaking() {
1100 let e = edge("body", "body.text", "prop", Some("text"));
1101 let diff = SchemaDiff {
1102 removed_required: HashMap::from([("body".into(), vec![e])]),
1103 ..SchemaDiff::default()
1104 };
1105 let report = classify(&diff, &test_protocol());
1106 assert!(!report.compatible);
1107 assert!(
1108 report
1109 .breaking
1110 .iter()
1111 .any(|b| matches!(b, BreakingChange::RequiredEdgeRemoved { .. }))
1112 );
1113 }
1114
1115 #[test]
1120 fn classify_added_variant_as_breaking_under_unknown_openness() {
1121 let diff = SchemaDiff {
1122 added_variants: vec![Variant {
1123 id: "v2".into(),
1124 parent_vertex: "u".into(),
1125 tag: Some("b".into()),
1126 }],
1127 ..SchemaDiff::default()
1128 };
1129 let report = classify(&diff, &test_protocol());
1130 assert!(!report.compatible, "added variant defaults to breaking");
1131 assert!(
1132 report
1133 .breaking
1134 .iter()
1135 .any(|b| matches!(b, BreakingChange::AddedVariant { .. }))
1136 );
1137 }
1138
1139 #[test]
1140 fn classify_modified_variant_as_breaking() {
1141 let diff = SchemaDiff {
1142 modified_variants: vec![VariantChange {
1143 id: "v1".into(),
1144 parent_vertex: "u".into(),
1145 old_tag: Some("a".into()),
1146 new_tag: Some("b".into()),
1147 }],
1148 ..SchemaDiff::default()
1149 };
1150 let report = classify(&diff, &test_protocol());
1151 assert!(!report.compatible, "modified variant is breaking");
1152 assert!(
1153 report
1154 .breaking
1155 .iter()
1156 .any(|b| matches!(b, BreakingChange::ModifiedVariant { .. }))
1157 );
1158 }
1159
1160 #[test]
1165 fn classify_constraint_tightening_as_breaking() {
1166 let diff = SchemaDiff {
1167 modified_constraints: std::iter::once((
1168 "body.text".into(),
1169 ConstraintDiff {
1170 added: vec![],
1171 removed: vec![],
1172 changed: vec![ConstraintChange {
1173 sort: "maxLength".into(),
1174 old_value: "3000".into(),
1175 new_value: "300".into(),
1176 }],
1177 },
1178 ))
1179 .collect(),
1180 ..SchemaDiff::default()
1181 };
1182
1183 let report = classify(&diff, &test_protocol());
1184 assert!(
1185 !report.compatible,
1186 "tightening maxLength should be breaking"
1187 );
1188 assert!(
1189 report
1190 .breaking
1191 .iter()
1192 .any(|b| matches!(b, BreakingChange::ConstraintTightened { .. }))
1193 );
1194 }
1195
1196 #[test]
1197 fn classify_constraint_relaxing_as_non_breaking() {
1198 let diff = SchemaDiff {
1199 modified_constraints: std::iter::once((
1200 "body.text".into(),
1201 ConstraintDiff {
1202 added: vec![],
1203 removed: vec![],
1204 changed: vec![ConstraintChange {
1205 sort: "maxLength".into(),
1206 old_value: "300".into(),
1207 new_value: "3000".into(),
1208 }],
1209 },
1210 ))
1211 .collect(),
1212 ..SchemaDiff::default()
1213 };
1214
1215 let report = classify(&diff, &test_protocol());
1216 assert!(
1217 report.compatible,
1218 "relaxing maxLength should be non-breaking"
1219 );
1220 assert!(
1221 report
1222 .non_breaking
1223 .iter()
1224 .any(|nb| matches!(nb, NonBreakingChange::ConstraintRelaxed { .. }))
1225 );
1226 }
1227
1228 #[test]
1229 fn classify_unlisted_sort_constraint_change_as_breaking() {
1230 let diff = SchemaDiff {
1234 modified_constraints: std::iter::once((
1235 "body.text".into(),
1236 ConstraintDiff {
1237 added: vec![],
1238 removed: vec![],
1239 changed: vec![ConstraintChange {
1240 sort: "customSort".into(),
1241 old_value: "a".into(),
1242 new_value: "b".into(),
1243 }],
1244 },
1245 ))
1246 .collect(),
1247 ..SchemaDiff::default()
1248 };
1249
1250 let report = classify(&diff, &test_protocol());
1251 assert!(
1252 !report.compatible,
1253 "a change on an unlisted constraint sort must be breaking"
1254 );
1255 }
1256
1257 #[test]
1258 fn classify_added_constraint_on_unlisted_sort_as_breaking() {
1259 let diff = SchemaDiff {
1260 modified_constraints: std::iter::once((
1261 "body.text".into(),
1262 ConstraintDiff {
1263 added: vec![Constraint {
1264 sort: "customSort".into(),
1265 value: "v".into(),
1266 }],
1267 removed: vec![],
1268 changed: vec![],
1269 },
1270 ))
1271 .collect(),
1272 ..SchemaDiff::default()
1273 };
1274 let report = classify(&diff, &test_protocol());
1275 assert!(!report.compatible);
1276 assert!(
1277 report
1278 .breaking
1279 .iter()
1280 .any(|b| matches!(b, BreakingChange::ConstraintAdded { .. }))
1281 );
1282 }
1283
1284 #[test]
1285 fn classify_kind_change_as_breaking() {
1286 let diff = SchemaDiff {
1287 kind_changes: vec![KindChange {
1288 vertex_id: "x".into(),
1289 old_kind: "string".into(),
1290 new_kind: "integer".into(),
1291 }],
1292 ..SchemaDiff::default()
1293 };
1294
1295 let report = classify(&diff, &test_protocol());
1296 assert!(!report.compatible, "kind change should be breaking");
1297 }
1298
1299 #[test]
1300 fn classify_removed_non_governed_edge_as_non_breaking() {
1301 let diff = SchemaDiff {
1302 removed_edges: vec![edge("body", "body.note", "annotation", Some("note"))],
1303 ..SchemaDiff::default()
1304 };
1305
1306 let report = classify(&diff, &test_protocol());
1307 assert!(report.compatible);
1308 assert_eq!(report.non_breaking.len(), 1);
1309 assert!(report.non_breaking.iter().any(
1310 |nb| matches!(nb, NonBreakingChange::RemovedEdge { kind, .. } if kind == "annotation")
1311 ),);
1312 }
1313
1314 #[test]
1315 fn classify_removed_governed_edge_as_breaking() {
1316 let diff = SchemaDiff {
1317 removed_edges: vec![edge("body", "body.text", "prop", Some("text"))],
1318 ..SchemaDiff::default()
1319 };
1320
1321 let report = classify(&diff, &test_protocol());
1322 assert!(!report.compatible);
1323 assert_eq!(report.breaking.len(), 1);
1324 assert!(
1325 report
1326 .breaking
1327 .iter()
1328 .any(|b| matches!(b, BreakingChange::RemovedEdge { kind, .. } if kind == "prop"))
1329 );
1330 }
1331
1332 #[test]
1337 fn nsid_add_non_breaking_change_remove_breaking() {
1338 let added = SchemaDiff {
1339 added_nsids: HashMap::from([("a".into(), "com.example.thing".into())]),
1340 ..SchemaDiff::default()
1341 };
1342 assert!(classify(&added, &test_protocol()).compatible);
1343
1344 let changed = SchemaDiff {
1345 changed_nsids: vec![("a".into(), "com.old".into(), "com.new".into())],
1346 ..SchemaDiff::default()
1347 };
1348 assert!(!classify(&changed, &test_protocol()).compatible);
1349
1350 let removed = SchemaDiff {
1351 removed_nsids: vec!["a".into()],
1352 ..SchemaDiff::default()
1353 };
1354 assert!(!classify(&removed, &test_protocol()).compatible);
1355 }
1356
1357 #[test]
1358 fn hyper_edge_add_non_breaking_remove_modify_breaking() {
1359 let added = SchemaDiff {
1360 added_hyper_edges: vec!["he1".into()],
1361 ..SchemaDiff::default()
1362 };
1363 assert!(classify(&added, &test_protocol()).compatible);
1364
1365 let removed = SchemaDiff {
1366 removed_hyper_edges: vec!["he1".into()],
1367 ..SchemaDiff::default()
1368 };
1369 assert!(!classify(&removed, &test_protocol()).compatible);
1370
1371 let modified = SchemaDiff {
1372 modified_hyper_edges: vec![HyperEdgeChange {
1373 id: "he1".into(),
1374 kind_change: Some(("join".into(), "merge".into())),
1375 signature_added: HashMap::new(),
1376 signature_removed: HashMap::new(),
1377 signature_changed: HashMap::new(),
1378 parent_label_change: None,
1379 }],
1380 ..SchemaDiff::default()
1381 };
1382 assert!(!classify(&modified, &test_protocol()).compatible);
1383 }
1384
1385 #[test]
1386 fn span_add_non_breaking_remove_modify_breaking() {
1387 let added = SchemaDiff {
1388 added_spans: vec!["s1".into()],
1389 ..SchemaDiff::default()
1390 };
1391 assert!(classify(&added, &test_protocol()).compatible);
1392
1393 let removed = SchemaDiff {
1394 removed_spans: vec!["s1".into()],
1395 ..SchemaDiff::default()
1396 };
1397 assert!(!classify(&removed, &test_protocol()).compatible);
1398
1399 let modified = SchemaDiff {
1400 modified_spans: vec![SpanChange {
1401 id: "s1".into(),
1402 left_change: Some(("a".into(), "b".into())),
1403 right_change: None,
1404 }],
1405 ..SchemaDiff::default()
1406 };
1407 assert!(!classify(&modified, &test_protocol()).compatible);
1408 }
1409
1410 #[test]
1411 fn nominal_flip_breaking_both_directions() {
1412 for (old, new) in [(false, true), (true, false)] {
1413 let diff = SchemaDiff {
1414 nominal_changes: vec![("a".into(), old, new)],
1415 ..SchemaDiff::default()
1416 };
1417 assert!(
1418 !classify(&diff, &test_protocol()).compatible,
1419 "nominal flip {old}->{new} must be breaking"
1420 );
1421 }
1422 }
1423
1424 #[test]
1425 fn recursion_point_add_remove_modify_breaking() {
1426 let added = SchemaDiff {
1427 added_recursion_points: vec![(
1428 "m".into(),
1429 RecursionPoint {
1430 target_vertex: "t".into(),
1431 },
1432 )],
1433 ..SchemaDiff::default()
1434 };
1435 assert!(!classify(&added, &test_protocol()).compatible);
1436
1437 let removed = SchemaDiff {
1438 removed_recursion_points: vec![(
1439 "m".into(),
1440 RecursionPoint {
1441 target_vertex: "t".into(),
1442 },
1443 )],
1444 ..SchemaDiff::default()
1445 };
1446 assert!(!classify(&removed, &test_protocol()).compatible);
1447
1448 let modified = SchemaDiff {
1449 modified_recursion_points: vec![RecursionPointChange {
1450 mu_id: "m".into(),
1451 old_target: "a".into(),
1452 new_target: "b".into(),
1453 }],
1454 ..SchemaDiff::default()
1455 };
1456 assert!(!classify(&modified, &test_protocol()).compatible);
1457 }
1458
1459 #[test]
1460 fn ordering_transitions_breaking() {
1461 let e = edge("a", "b", "prop", None);
1462 let to_unordered = SchemaDiff {
1463 order_changes: vec![(e.clone(), Some(0), None)],
1464 ..SchemaDiff::default()
1465 };
1466 assert!(!classify(&to_unordered, &test_protocol()).compatible);
1467
1468 let to_ordered = SchemaDiff {
1469 order_changes: vec![(e.clone(), None, Some(0))],
1470 ..SchemaDiff::default()
1471 };
1472 let report = classify(&to_ordered, &test_protocol());
1473 assert!(!report.compatible);
1474 assert!(
1475 report
1476 .breaking
1477 .iter()
1478 .any(|b| matches!(b, BreakingChange::UnorderedToOrdered { .. }))
1479 );
1480
1481 let reordered = SchemaDiff {
1482 order_changes: vec![(e, Some(0), Some(1))],
1483 ..SchemaDiff::default()
1484 };
1485 let report = classify(&reordered, &test_protocol());
1486 assert!(!report.compatible);
1487 assert!(
1488 report
1489 .breaking
1490 .iter()
1491 .any(|b| matches!(b, BreakingChange::UnclassifiedChange { .. }))
1492 );
1493 }
1494
1495 #[test]
1496 fn usage_mode_tighten_breaking_relax_non_breaking() {
1497 let e = edge("a", "b", "prop", None);
1498 let tighten = SchemaDiff {
1499 usage_mode_changes: vec![(e.clone(), UsageMode::Structural, UsageMode::Linear)],
1500 ..SchemaDiff::default()
1501 };
1502 assert!(!classify(&tighten, &test_protocol()).compatible);
1503
1504 let relax = SchemaDiff {
1505 usage_mode_changes: vec![(e, UsageMode::Linear, UsageMode::Structural)],
1506 ..SchemaDiff::default()
1507 };
1508 assert!(classify(&relax, &test_protocol()).compatible);
1509 }
1510
1511 #[test]
1512 fn enrichment_add_non_breaking_remove_modify_breaking() {
1513 let added = SchemaDiff {
1514 added_coercions: vec![("a".into(), "b".into())],
1515 added_mergers: vec!["m".into()],
1516 added_defaults: vec!["d".into()],
1517 added_policies: vec!["p".into()],
1518 ..SchemaDiff::default()
1519 };
1520 assert!(classify(&added, &test_protocol()).compatible);
1521
1522 let removed = SchemaDiff {
1523 removed_coercions: vec![("a".into(), "b".into())],
1524 ..SchemaDiff::default()
1525 };
1526 assert!(!classify(&removed, &test_protocol()).compatible);
1527
1528 let modified = SchemaDiff {
1529 modified_policies: vec!["p".into()],
1530 ..SchemaDiff::default()
1531 };
1532 assert!(!classify(&modified, &test_protocol()).compatible);
1533 }
1534
1535 #[test]
1541 #[allow(clippy::too_many_lines)]
1542 fn fail_closed_every_category_is_classified() {
1543 let e = edge("a", "b", "prop", None);
1544 let breaking_cases: Vec<(&str, SchemaDiff)> = vec![
1545 (
1546 "removed_vertices",
1547 SchemaDiff {
1548 removed_vertices: vec!["a".into()],
1549 ..SchemaDiff::default()
1550 },
1551 ),
1552 (
1553 "kind_changes",
1554 SchemaDiff {
1555 kind_changes: vec![KindChange {
1556 vertex_id: "a".into(),
1557 old_kind: "x".into(),
1558 new_kind: "y".into(),
1559 }],
1560 ..SchemaDiff::default()
1561 },
1562 ),
1563 (
1564 "removed_hyper_edges",
1565 SchemaDiff {
1566 removed_hyper_edges: vec!["he".into()],
1567 ..SchemaDiff::default()
1568 },
1569 ),
1570 (
1571 "added_required",
1572 SchemaDiff {
1573 added_required: HashMap::from([("a".into(), vec![e.clone()])]),
1574 ..SchemaDiff::default()
1575 },
1576 ),
1577 (
1578 "removed_required",
1579 SchemaDiff {
1580 removed_required: HashMap::from([("a".into(), vec![e.clone()])]),
1581 ..SchemaDiff::default()
1582 },
1583 ),
1584 (
1585 "changed_nsids",
1586 SchemaDiff {
1587 changed_nsids: vec![("a".into(), "x".into(), "y".into())],
1588 ..SchemaDiff::default()
1589 },
1590 ),
1591 (
1592 "removed_nsids",
1593 SchemaDiff {
1594 removed_nsids: vec!["a".into()],
1595 ..SchemaDiff::default()
1596 },
1597 ),
1598 (
1599 "added_variants",
1600 SchemaDiff {
1601 added_variants: vec![Variant {
1602 id: "v".into(),
1603 parent_vertex: "u".into(),
1604 tag: None,
1605 }],
1606 ..SchemaDiff::default()
1607 },
1608 ),
1609 (
1610 "removed_variants",
1611 SchemaDiff {
1612 removed_variants: vec![Variant {
1613 id: "v".into(),
1614 parent_vertex: "u".into(),
1615 tag: None,
1616 }],
1617 ..SchemaDiff::default()
1618 },
1619 ),
1620 (
1621 "order_to_unordered",
1622 SchemaDiff {
1623 order_changes: vec![(e.clone(), Some(0), None)],
1624 ..SchemaDiff::default()
1625 },
1626 ),
1627 (
1628 "unordered_to_ordered",
1629 SchemaDiff {
1630 order_changes: vec![(e, None, Some(0))],
1631 ..SchemaDiff::default()
1632 },
1633 ),
1634 (
1635 "added_recursion_points",
1636 SchemaDiff {
1637 added_recursion_points: vec![(
1638 "m".into(),
1639 RecursionPoint {
1640 target_vertex: "t".into(),
1641 },
1642 )],
1643 ..SchemaDiff::default()
1644 },
1645 ),
1646 (
1647 "modified_recursion_points",
1648 SchemaDiff {
1649 modified_recursion_points: vec![RecursionPointChange {
1650 mu_id: "m".into(),
1651 old_target: "a".into(),
1652 new_target: "b".into(),
1653 }],
1654 ..SchemaDiff::default()
1655 },
1656 ),
1657 (
1658 "removed_spans",
1659 SchemaDiff {
1660 removed_spans: vec!["s".into()],
1661 ..SchemaDiff::default()
1662 },
1663 ),
1664 (
1665 "nominal_changes",
1666 SchemaDiff {
1667 nominal_changes: vec![("a".into(), false, true)],
1668 ..SchemaDiff::default()
1669 },
1670 ),
1671 (
1672 "removed_coercions",
1673 SchemaDiff {
1674 removed_coercions: vec![("a".into(), "b".into())],
1675 ..SchemaDiff::default()
1676 },
1677 ),
1678 (
1679 "removed_mergers",
1680 SchemaDiff {
1681 removed_mergers: vec!["m".into()],
1682 ..SchemaDiff::default()
1683 },
1684 ),
1685 (
1686 "removed_defaults",
1687 SchemaDiff {
1688 removed_defaults: vec!["d".into()],
1689 ..SchemaDiff::default()
1690 },
1691 ),
1692 (
1693 "removed_policies",
1694 SchemaDiff {
1695 removed_policies: vec!["p".into()],
1696 ..SchemaDiff::default()
1697 },
1698 ),
1699 ];
1700
1701 for (label, diff) in &breaking_cases {
1702 let report = classify(diff, &test_protocol());
1703 assert!(
1704 !report.compatible,
1705 "category {label} must classify as breaking"
1706 );
1707 assert_eq!(report.classification, Classification::Breaking, "{label}");
1708 }
1709
1710 let non_breaking_cases: Vec<(&str, SchemaDiff)> = vec![
1712 (
1713 "added_nsids",
1714 SchemaDiff {
1715 added_nsids: HashMap::from([("a".into(), "x".into())]),
1716 ..SchemaDiff::default()
1717 },
1718 ),
1719 (
1720 "added_hyper_edges",
1721 SchemaDiff {
1722 added_hyper_edges: vec!["he".into()],
1723 ..SchemaDiff::default()
1724 },
1725 ),
1726 (
1727 "added_spans",
1728 SchemaDiff {
1729 added_spans: vec!["s".into()],
1730 ..SchemaDiff::default()
1731 },
1732 ),
1733 (
1734 "added_coercions",
1735 SchemaDiff {
1736 added_coercions: vec![("a".into(), "b".into())],
1737 ..SchemaDiff::default()
1738 },
1739 ),
1740 (
1741 "added_mergers",
1742 SchemaDiff {
1743 added_mergers: vec!["m".into()],
1744 ..SchemaDiff::default()
1745 },
1746 ),
1747 (
1748 "added_defaults",
1749 SchemaDiff {
1750 added_defaults: vec!["d".into()],
1751 ..SchemaDiff::default()
1752 },
1753 ),
1754 (
1755 "added_policies",
1756 SchemaDiff {
1757 added_policies: vec!["p".into()],
1758 ..SchemaDiff::default()
1759 },
1760 ),
1761 ];
1762
1763 for (label, diff) in &non_breaking_cases {
1764 let report = classify(diff, &test_protocol());
1765 assert!(report.compatible, "category {label} should be non-breaking");
1766 assert_eq!(
1767 report.classification,
1768 Classification::BackwardCompatible,
1769 "{label}"
1770 );
1771 assert!(!report.non_breaking.is_empty(), "{label} produced no entry");
1772 }
1773 }
1774
1775 #[test]
1780 fn classify_rename_suppresses_removed_added_pair() {
1781 let diff = SchemaDiff {
1782 removed_vertices: vec!["root.text".into()],
1783 added_vertices: vec!["root.body".into()],
1784 renamed_vertices: vec![("root.text".into(), "root.body".into())],
1785 ..SchemaDiff::default()
1786 };
1787
1788 let report = classify(&diff, &test_protocol());
1789 assert_eq!(
1790 report.breaking.len(),
1791 1,
1792 "only the rename should be breaking"
1793 );
1794 assert!(report.breaking.iter().any(
1795 |b| matches!(b, BreakingChange::RenamedVertex { old_id, new_id }
1796 if old_id == "root.text" && new_id == "root.body")
1797 ));
1798 assert!(
1799 !report
1800 .breaking
1801 .iter()
1802 .any(|b| matches!(b, BreakingChange::RemovedVertex { .. })),
1803 "the removed vertex must be suppressed"
1804 );
1805 assert!(
1806 report.non_breaking.is_empty(),
1807 "the added vertex must be suppressed"
1808 );
1809 }
1810
1811 #[test]
1816 fn classify_with_schemas_sets_classification() {
1817 let diff = SchemaDiff {
1818 added_vertices: vec!["x".into()],
1819 ..SchemaDiff::default()
1820 };
1821 let schema = empty_schema();
1822 let report = classify_with_schemas(&diff, &test_protocol(), &schema, &schema);
1823 assert_eq!(report.classification, Classification::BackwardCompatible);
1824 }
1825
1826 fn empty_schema() -> panproto_schema::Schema {
1827 panproto_schema::Schema {
1828 protocol: "test".into(),
1829 vertices: HashMap::new(),
1830 edges: HashMap::new(),
1831 hyper_edges: HashMap::new(),
1832 constraints: HashMap::new(),
1833 required: HashMap::new(),
1834 nsids: HashMap::new(),
1835 entries: Vec::new(),
1836 variants: HashMap::new(),
1837 orderings: HashMap::new(),
1838 recursion_points: HashMap::new(),
1839 spans: HashMap::new(),
1840 usage_modes: HashMap::new(),
1841 nominal: HashMap::new(),
1842 coercions: HashMap::new(),
1843 mergers: HashMap::new(),
1844 defaults: HashMap::new(),
1845 policies: HashMap::new(),
1846 outgoing: HashMap::new(),
1847 incoming: HashMap::new(),
1848 between: HashMap::new(),
1849 }
1850 }
1851}