1use std::collections::{BTreeMap, BTreeSet};
26
27use ridl_ir::v2;
28
29use crate::{Category, Change, Verdict, frozen};
30
31#[cfg(test)]
32mod classify_tests;
33
34pub fn classify(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
47 classify_in(change, old, new, &[], &[])
48}
49
50#[deny(
64 clippy::wildcard_enum_match_arm,
65 clippy::match_wildcard_for_single_variants
66)]
67pub(crate) fn classify_in(
68 change: &Change,
69 old: &v2::Package,
70 new: &v2::Package,
71 scope: &[&v2::Package],
72 encodings: &[&str],
73) -> Verdict {
74 match change.category {
75 Category::InteractionInserted
78 | Category::InteractionReordered
79 | Category::MemberReordered
80 | Category::InteractionRemoved
81 | Category::ReservedNameRedeclared
82 | Category::KindChanged
83 | Category::PayloadChanged
84 | Category::ReturnChanged
85 | Category::ParamsChanged
86 | Category::WidthChanged
87 | Category::ServiceChanged
88 | Category::DeclRemoved => Verdict::Breaking,
89
90 Category::InitChanged => Verdict::Breaking,
94
95 Category::DocOnly | Category::InteractionRetired => Verdict::Compatible,
102
103 Category::InterfaceRenamed | Category::InterfaceRetired => Verdict::Compatible,
110
111 Category::InterfaceFrozen => Verdict::Compatible,
117 Category::InterfaceNumberChanged => Verdict::Breaking,
118
119 Category::LimitChanged => Verdict::Breaking,
123
124 Category::EnumReordered => Verdict::Compatible,
130
131 Category::StrictEnumAppend | Category::EnumClosed => Verdict::Breaking,
136 Category::EnumOpened => Verdict::Compatible,
137
138 Category::ServiceInterfaceAdded | Category::ServiceInterfaceRemoved => Verdict::Compatible,
146
147 Category::VisibilityChanged => visibility(change, old, new),
148 Category::InteractionAppended => appended(change, old, new),
149 Category::DeclAdded => added(change, old, new, scope),
150 Category::ConstraintChanged => constraint(change, old, new),
151 Category::TimingChanged => timing(change, old, new),
152 Category::RpcBoundChanged => rpc_bound(change, old, new),
153 Category::ContractChanged => contract(change, old, new),
154 Category::InteractionClassChanged => Verdict::Breaking,
158
159 Category::PodbufInlineAppend
167 | Category::PodbufRemoteInlineGrowth
168 | Category::PodbufWidthChange
169 | Category::PodbufStrictEnumAppend => {
170 if encodings.contains(&"podbuf") {
171 Verdict::Breaking
172 } else {
173 Verdict::Compatible
174 }
175 }
176 Category::FlatbuffersWidthChange => {
177 if encodings.contains(&"flatbuffers") {
178 Verdict::Breaking
179 } else {
180 Verdict::Compatible
181 }
182 }
183 Category::Proto3WidthChange => {
184 if encodings.contains(&"proto3") {
185 Verdict::Breaking
186 } else {
187 Verdict::Compatible
188 }
189 }
190 }
191}
192
193fn visibility(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
211 let (Some(old_visibility), Some(new_visibility)) = (
212 find_visibility(old, &change.path, |name| find_old_shape(old, new, name)),
213 find_visibility(new, &change.path, |name| find_shape(new, name)),
214 ) else {
215 return Verdict::Breaking;
216 };
217
218 match (
219 v2::Visibility::try_from(old_visibility),
220 v2::Visibility::try_from(new_visibility),
221 ) {
222 (Ok(v2::Visibility::Internal), Ok(v2::Visibility::Public)) => Verdict::Compatible,
223 _ => Verdict::Breaking,
224 }
225}
226
227fn find_visibility<'a>(
234 package: &'a v2::Package,
235 path: &str,
236 shape_of: impl Fn(&str) -> Option<v2::InterfaceShape<'a>>,
237) -> Option<i32> {
238 let mut segments = path.split('/').skip(1);
239 let name = segments.next()?;
240
241 if let Some(member) = segments.next() {
242 return shape_of(name)?
243 .interface
244 .interactions
245 .iter()
246 .find(|decl| decl.name == member)
247 .map(|decl| decl.visibility);
248 }
249 if let Some(decl) = find_decl(package, name) {
250 return Some(decl.visibility);
251 }
252 if let Some(shape) = shape_of(name) {
257 return Some(shape.visibility());
258 }
259 package
262 .services
263 .iter()
264 .find(|service| service.name == name)
265 .map(|service| service.visibility)
266}
267
268fn appended(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
283 let Some((container, member)) = member_path(change) else {
284 return Verdict::Breaking;
285 };
286 let (Some(old_iface), Some(new_iface)) = (
287 find_old_interface(old, new, container),
288 find_interface(new, container),
289 ) else {
290 return Verdict::Breaking;
291 };
292 let Some(ordinal) = slot_ordinal(new_iface, member) else {
293 return Verdict::Breaking;
294 };
295
296 for (name, old_ordinal) in slots(old_iface) {
297 if old_ordinal == ordinal && name != member {
298 return Verdict::Breaking;
299 }
300 }
301 Verdict::Compatible
302}
303
304fn added(change: &Change, old: &v2::Package, new: &v2::Package, scope: &[&v2::Package]) -> Verdict {
320 let Some((container, member)) = member_path(change) else {
321 return Verdict::Compatible;
324 };
325 let (Some(old_decl), Some(new_decl)) = (find_decl(old, container), find_decl(new, container))
326 else {
327 return Verdict::Breaking;
328 };
329
330 use v2::decl::Kind;
335 let appended = match (&old_decl.kind, &new_decl.kind) {
336 (Some(Kind::StructDef(old_def)), Some(Kind::StructDef(new_def))) => {
337 appended_slot(
338 &struct_slots(old_def),
339 &struct_reserved(old_def),
340 &struct_slots(new_def),
341 ) && absence_reads_as_legal(new_def, member, new, scope)
342 }
343 (Some(Kind::UnionDef(old_def)), Some(Kind::UnionDef(new_def))) => {
344 !old_def.is_result
347 && !new_def.is_result
348 && appended_slot(
349 &union_slots(old_def),
350 &union_reserved(old_def),
351 &union_slots(new_def),
352 )
353 }
354 (Some(Kind::EnumDef(old_def)), Some(Kind::EnumDef(new_def))) => {
359 new_def.open
360 && appended_slot(
361 &value_slots(&old_def.values),
362 &reserved_values(&old_def.reserved),
363 &value_slots(&new_def.values),
364 )
365 }
366 (Some(Kind::EnumSetDef(old_def)), Some(Kind::EnumSetDef(new_def))) => {
367 new_def.open
368 && appended_slot(
369 &value_slots(&old_def.bits),
370 &[],
371 &value_slots(&new_def.bits),
372 )
373 }
374 _ => false,
376 };
377
378 if appended {
379 Verdict::Compatible
380 } else {
381 Verdict::Breaking
382 }
383}
384
385pub(crate) fn is_strict_enum_append(old_decl: &v2::Decl, new_decl: &v2::Decl) -> bool {
390 use v2::decl::Kind;
391 match (&old_decl.kind, &new_decl.kind) {
392 (Some(Kind::EnumDef(old_def)), Some(Kind::EnumDef(new_def))) => {
393 !new_def.open
394 && appended_slot(
395 &value_slots(&old_def.values),
396 &reserved_values(&old_def.reserved),
397 &value_slots(&new_def.values),
398 )
399 }
400 (Some(Kind::EnumSetDef(old_def)), Some(Kind::EnumSetDef(new_def))) => {
401 !new_def.open
402 && appended_slot(
403 &value_slots(&old_def.bits),
404 &[],
405 &value_slots(&new_def.bits),
406 )
407 }
408 _ => false,
409 }
410}
411
412pub fn absence_refused(
421 new_set: &[v2::Package],
422 context: &[v2::Package],
423 package: &str,
424 container: &str,
425 member: &str,
426) -> bool {
427 let scope: Vec<&v2::Package> = new_set.iter().chain(context).collect();
428 let Some(home) = new_set.iter().find(|candidate| candidate.name == package) else {
429 return false;
430 };
431 match find_decl(home, container).and_then(|decl| decl.kind.as_ref()) {
432 Some(v2::decl::Kind::StructDef(def)) => !absence_reads_as_legal(def, member, home, &scope),
433 _ => false,
434 }
435}
436
437fn absence_reads_as_legal(
457 def: &v2::StructDef,
458 member: &str,
459 home: &v2::Package,
460 scope: &[&v2::Package],
461) -> bool {
462 let Some(r#type) = def.members.iter().find_map(|slot| match &slot.member {
463 Some(v2::struct_member::Member::Field(field)) if field.name == member => {
464 field.r#type.as_ref()
465 }
466 _ => None,
467 }) else {
468 return false;
469 };
470 ridl_ir::zero::field_type_holds_zero(r#type, |reference| resolve(home, scope, reference))
471}
472
473fn resolve<'a>(
477 home: &'a v2::Package,
478 scope: &[&'a v2::Package],
479 reference: &str,
480) -> Option<&'a v2::Decl> {
481 match reference.rsplit_once('.') {
482 Some((package, name)) => std::iter::once(home)
483 .chain(scope.iter().copied())
484 .find(|candidate| candidate.name == package)
485 .and_then(|candidate| find_decl(candidate, name)),
486 None => find_decl(home, reference),
487 }
488}
489
490fn appended_slot(old: &[(String, i64)], old_retired: &[i64], new: &[(String, i64)]) -> bool {
501 let mut new_slots: BTreeMap<&str, i64> = BTreeMap::new();
503 for (name, slot) in new {
504 new_slots.entry(name.as_str()).or_insert(*slot);
505 }
506 for (name, old_slot) in old {
507 match new_slots.get(name.as_str()) {
508 Some(new_slot) if new_slot == old_slot => {}
509 _ => return false,
511 }
512 }
513
514 let mark = old
517 .iter()
518 .map(|(_, slot)| *slot)
519 .chain(old_retired.iter().copied())
520 .max();
521 let old_names: BTreeSet<&str> = old.iter().map(|(name, _)| name.as_str()).collect();
522 for (name, slot) in new {
523 if old_names.contains(name.as_str()) {
524 continue;
525 }
526 if !is_above_high_water(*slot, mark) {
527 return false;
528 }
529 }
530 true
531}
532
533pub fn is_above_high_water(slot: i64, held: impl IntoIterator<Item = i64>) -> bool {
542 held.into_iter().max().is_none_or(|mark| slot > mark)
543}
544
545fn constraint(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
557 let mut segments = change.path.split('/').skip(1);
558 let (Some(name), None) = (segments.next(), segments.next()) else {
559 return Verdict::Breaking;
560 };
561 let (Some(old_decl), Some(new_decl)) = (find_decl(old, name), find_decl(new, name)) else {
562 return Verdict::Breaking;
563 };
564
565 use v2::decl::Kind;
566 let (Some(Kind::TypeDef(old_def)), Some(Kind::TypeDef(new_def))) =
567 (&old_decl.kind, &new_decl.kind)
568 else {
569 return Verdict::Breaking;
570 };
571
572 if narrows(old_def.constraint.as_ref(), new_def.constraint.as_ref()) {
573 Verdict::Breaking
574 } else {
575 Verdict::Compatible
576 }
577}
578
579fn narrows(old: Option<&v2::Constraint>, new: Option<&v2::Constraint>) -> bool {
586 let (Some(old), Some(new)) = (old, new) else {
587 return old.is_none() && new.is_some();
590 };
591
592 if raised(old.min.as_deref(), new.min.as_deref())
594 || lowered(old.max.as_deref(), new.max.as_deref())
595 {
596 return true;
597 }
598 if raised_u64(old.len_min, new.len_min) || lowered_u64(old.len_max, new.len_max) {
600 return true;
601 }
602 if new.step.is_some() && old.step != new.step {
605 return true;
606 }
607 if let Some(step) = new.step.as_deref()
610 && old.min != new.min
611 && !same_grid(old.min.as_deref(), new.min.as_deref(), step)
612 {
613 return true;
614 }
615 if (new.pattern.is_some() && old.pattern != new.pattern)
618 || (new.pattern_const.is_some() && old.pattern_const != new.pattern_const)
619 {
620 return true;
621 }
622 false
623}
624
625fn same_grid(old: Option<&str>, new: Option<&str>, step: &str) -> bool {
629 use num_bigint::BigInt;
630
631 let read = |text: &str| -> Option<(BigInt, usize)> {
632 let (negative, digits) = split_sign(text)?;
633 let (whole, fraction) = digits.split_once('.').unwrap_or((&digits, ""));
634 if whole.is_empty() || (digits.contains('.') && fraction.is_empty()) {
635 return None;
636 }
637 let coefficient = BigInt::parse_bytes(format!("{whole}{fraction}").as_bytes(), 10)?;
638 Some((
639 if negative { -coefficient } else { coefficient },
640 fraction.len(),
641 ))
642 };
643 let (Some(old), Some(new), Some(step)) = (
644 read(old.unwrap_or("0")),
645 read(new.unwrap_or("0")),
646 read(step),
647 ) else {
648 return false;
649 };
650 let scale = old.1.max(new.1).max(step.1);
651 let rescale = |(coefficient, places): (BigInt, usize)| -> Option<BigInt> {
652 Some(coefficient * BigInt::from(10).pow(u32::try_from(scale - places).ok()?))
653 };
654 let (Some(old), Some(new), Some(step)) = (rescale(old), rescale(new), rescale(step)) else {
655 return false;
656 };
657 step > BigInt::from(0) && (old - new) % step == BigInt::from(0)
658}
659
660fn raised(old: Option<&str>, new: Option<&str>) -> bool {
662 match (old, new) {
663 (None, Some(_)) => true,
664 (Some(old), Some(new)) => cmp_decimal(new, old).is_none_or(std::cmp::Ordering::is_gt),
665 _ => false,
666 }
667}
668
669fn lowered(old: Option<&str>, new: Option<&str>) -> bool {
671 match (old, new) {
672 (None, Some(_)) => true,
673 (Some(old), Some(new)) => cmp_decimal(new, old).is_none_or(std::cmp::Ordering::is_lt),
674 _ => false,
675 }
676}
677
678fn raised_u64(old: Option<u64>, new: Option<u64>) -> bool {
679 match (old, new) {
680 (None, Some(_)) => true,
681 (Some(old), Some(new)) => new > old,
682 _ => false,
683 }
684}
685
686fn lowered_u64(old: Option<u64>, new: Option<u64>) -> bool {
687 match (old, new) {
688 (None, Some(_)) => true,
689 (Some(old), Some(new)) => new < old,
690 _ => false,
691 }
692}
693
694fn cmp_decimal(left: &str, right: &str) -> Option<std::cmp::Ordering> {
704 use std::cmp::Ordering;
705
706 let (left_negative, left_digits) = split_sign(left)?;
707 let (right_negative, right_digits) = split_sign(right)?;
708 if left_negative != right_negative {
709 return Some(if left_negative {
712 Ordering::Less
713 } else {
714 Ordering::Greater
715 });
716 }
717
718 let magnitude = cmp_magnitude(&left_digits, &right_digits)?;
719 Some(if left_negative {
720 magnitude.reverse()
721 } else {
722 magnitude
723 })
724}
725
726fn split_sign(text: &str) -> Option<(bool, String)> {
729 let (negative, rest) = match text.strip_prefix('-') {
730 Some(rest) => (true, rest),
731 None => (false, text.strip_prefix('+').unwrap_or(text)),
732 };
733 if rest.is_empty() || !rest.chars().all(|c| c.is_ascii_digit() || c == '.') {
734 return None;
735 }
736 if rest.matches('.').count() > 1 {
737 return None;
738 }
739 Some((negative, rest.to_string()))
740}
741
742fn cmp_magnitude(left: &str, right: &str) -> Option<std::cmp::Ordering> {
745 use std::cmp::Ordering;
746
747 let (left_int, left_frac) = left.split_once('.').unwrap_or((left, ""));
748 let (right_int, right_frac) = right.split_once('.').unwrap_or((right, ""));
749
750 let left_int = left_int.trim_start_matches('0');
751 let right_int = right_int.trim_start_matches('0');
752 let by_length = left_int.len().cmp(&right_int.len());
753 if by_length != Ordering::Equal {
754 return Some(by_length);
755 }
756 let by_int = left_int.cmp(right_int);
757 if by_int != Ordering::Equal {
758 return Some(by_int);
759 }
760
761 let width = left_frac.len().max(right_frac.len());
763 let pad = |frac: &str| format!("{frac:0<width$}");
764 Some(pad(left_frac).cmp(&pad(right_frac)))
765}
766
767fn timing(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
779 let Some((container, member)) = member_path(change) else {
780 return Verdict::Breaking;
781 };
782 let (Some(old_decl), Some(new_decl)) = (
783 find_old_interaction(old, new, container, member),
784 find_interaction(new, container, member),
785 ) else {
786 return Verdict::Breaking;
787 };
788 let (Some(old_timing), Some(new_timing)) =
789 (interaction_timing(old_decl), interaction_timing(new_decl))
790 else {
791 return Verdict::Breaking;
801 };
802
803 let (Some(old_timing), Some(new_timing)) = (old_timing, new_timing) else {
804 return Verdict::Breaking;
807 };
808
809 if old_timing.mode != new_timing.mode {
810 return Verdict::Breaking;
811 }
812 if lowered(old_timing.min_us.as_deref(), new_timing.min_us.as_deref())
814 || dropped(old_timing.min_us.as_deref(), new_timing.min_us.as_deref())
815 || raised(old_timing.max_us.as_deref(), new_timing.max_us.as_deref())
816 || dropped(old_timing.max_us.as_deref(), new_timing.max_us.as_deref())
817 {
818 return Verdict::Breaking;
819 }
820 Verdict::Compatible
823}
824
825fn rpc_bound(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
844 let Some((container, member)) = member_path(change) else {
845 return Verdict::Breaking;
846 };
847 let (Some(old_decl), Some(new_decl)) = (
848 find_old_interaction(old, new, container, member),
849 find_interaction(new, container, member),
850 ) else {
851 return Verdict::Breaking;
852 };
853 let (Some(old_timing), Some(new_timing)) = (rpc_timing(old_decl), rpc_timing(new_decl)) else {
854 return Verdict::Breaking;
861 };
862
863 let (Some(old_timing), Some(new_timing)) = (old_timing, new_timing) else {
864 return Verdict::Breaking;
867 };
868
869 if old_timing.mode != new_timing.mode {
872 return Verdict::Breaking;
873 }
874 if raised(old_timing.min_us.as_deref(), new_timing.min_us.as_deref())
879 || dropped(old_timing.min_us.as_deref(), new_timing.min_us.as_deref())
880 || raised(old_timing.max_us.as_deref(), new_timing.max_us.as_deref())
881 || dropped(old_timing.max_us.as_deref(), new_timing.max_us.as_deref())
882 {
883 return Verdict::Breaking;
884 }
885 Verdict::Compatible
891}
892
893fn dropped(old: Option<&str>, new: Option<&str>) -> bool {
895 old.is_some() && new.is_none()
896}
897
898fn interaction_timing(decl: &v2::Decl) -> Option<Option<&v2::Timing>> {
901 use v2::decl::Kind;
902 match &decl.kind {
903 Some(Kind::SignalDef(def)) => Some(def.timing.as_ref()),
904 Some(Kind::EventDef(def)) => Some(def.timing.as_ref()),
905 _ => None,
906 }
907}
908
909fn rpc_timing(decl: &v2::Decl) -> Option<Option<&v2::Timing>> {
913 use v2::decl::Kind;
914 match &decl.kind {
915 Some(Kind::CommandDef(def)) => Some(def.timing.as_ref()),
916 Some(Kind::QueryDef(def)) => Some(def.timing.as_ref()),
917 _ => None,
918 }
919}
920
921fn contract(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
935 let Some((container, member)) = member_path(change) else {
936 return Verdict::Breaking;
937 };
938 let (Some(old_decl), Some(new_decl)) = (
939 find_old_interaction(old, new, container, member),
940 find_interaction(new, container, member),
941 ) else {
942 return Verdict::Breaking;
943 };
944 let (Some(old_clauses), Some(new_clauses)) = (contracts(old_decl), contracts(new_decl)) else {
945 return Verdict::Breaking;
946 };
947
948 let kind = |want: v2::ContractKind| {
949 move |clause: &&v2::Contract| v2::ContractKind::try_from(clause.kind) == Ok(want)
950 };
951 let sources = |clauses: &[v2::Contract], want: v2::ContractKind| -> Vec<String> {
952 let mut out: Vec<String> = clauses
953 .iter()
954 .filter(kind(want))
955 .map(|clause| clause.source.clone())
956 .collect();
957 out.sort();
958 out
959 };
960
961 let old_require = sources(old_clauses, v2::ContractKind::Require);
962 let new_require = sources(new_clauses, v2::ContractKind::Require);
963 let old_ensure = sources(old_clauses, v2::ContractKind::Ensure);
964 let new_ensure = sources(new_clauses, v2::ContractKind::Ensure);
965
966 if !covers(&old_require, &new_require) || !covers(&new_ensure, &old_ensure) {
967 return Verdict::Breaking;
968 }
969 Verdict::Compatible
970}
971
972fn covers(superset: &[String], subset: &[String]) -> bool {
974 let mut remaining: Vec<&String> = superset.iter().collect();
975 for clause in subset {
976 match remaining.iter().position(|held| *held == clause) {
977 Some(index) => {
978 remaining.swap_remove(index);
979 }
980 None => return false,
981 }
982 }
983 true
984}
985
986fn contracts(decl: &v2::Decl) -> Option<&[v2::Contract]> {
988 use v2::decl::Kind;
989 match &decl.kind {
990 Some(Kind::CommandDef(def)) => Some(&def.contracts),
991 Some(Kind::QueryDef(def)) => Some(&def.contracts),
992 _ => None,
993 }
994}
995
996fn member_path(change: &Change) -> Option<(&str, &str)> {
1003 let mut segments = change.path.split('/').skip(1);
1004 let container = segments.next()?;
1005 let member = segments.next()?;
1006 if segments.next().is_some() {
1007 return None;
1008 }
1009 Some((container, member))
1010}
1011
1012pub(crate) fn find_decl<'a>(package: &'a v2::Package, name: &str) -> Option<&'a v2::Decl> {
1013 package.decls.iter().find(|decl| decl.name == name)
1014}
1015
1016fn find_shape<'a>(package: &'a v2::Package, name: &str) -> Option<v2::InterfaceShape<'a>> {
1021 package.shapes().find(|shape| shape.name == name)
1022}
1023
1024fn find_old_shape<'a>(
1031 old: &'a v2::Package,
1032 new: &v2::Package,
1033 name: &str,
1034) -> Option<v2::InterfaceShape<'a>> {
1035 let by_number = find_shape(new, name)
1036 .filter(|shape| frozen(shape.interface))
1037 .and_then(|shape| {
1038 old.shapes().find(|candidate| {
1039 frozen(candidate.interface) && candidate.interface.number == shape.interface.number
1040 })
1041 });
1042 by_number.or_else(|| find_shape(old, name))
1043}
1044
1045fn find_interface<'a>(package: &'a v2::Package, name: &str) -> Option<&'a v2::Interface> {
1046 find_shape(package, name).map(|shape| shape.interface)
1047}
1048
1049fn find_old_interface<'a>(
1050 old: &'a v2::Package,
1051 new: &v2::Package,
1052 name: &str,
1053) -> Option<&'a v2::Interface> {
1054 find_old_shape(old, new, name).map(|shape| shape.interface)
1055}
1056
1057fn find_interaction<'a>(
1058 package: &'a v2::Package,
1059 container: &str,
1060 member: &str,
1061) -> Option<&'a v2::Decl> {
1062 find_interface(package, container)?
1063 .interactions
1064 .iter()
1065 .find(|decl| decl.name == member)
1066}
1067
1068fn find_old_interaction<'a>(
1071 old: &'a v2::Package,
1072 new: &v2::Package,
1073 container: &str,
1074 member: &str,
1075) -> Option<&'a v2::Decl> {
1076 find_old_interface(old, new, container)?
1077 .interactions
1078 .iter()
1079 .find(|decl| decl.name == member)
1080}
1081
1082fn slots(interface: &v2::Interface) -> Vec<(&str, u32)> {
1094 interface
1095 .interactions
1096 .iter()
1097 .filter_map(|decl| match &decl.kind {
1098 Some(v2::decl::Kind::ReservedSlot(reserved)) => {
1099 Some((reserved.name.as_deref().unwrap_or(""), decl.ordinal))
1100 }
1101 Some(_) => Some((decl.name.as_str(), decl.ordinal)),
1102 None => None,
1103 })
1104 .collect()
1105}
1106
1107fn slot_ordinal(interface: &v2::Interface, member: &str) -> Option<u32> {
1108 slots(interface)
1109 .into_iter()
1110 .find(|(name, _)| *name == member)
1111 .map(|(_, ordinal)| ordinal)
1112}
1113
1114pub(crate) fn struct_slots(def: &v2::StructDef) -> Vec<(String, i64)> {
1118 def.members
1119 .iter()
1120 .filter_map(|member| match &member.member {
1121 Some(v2::struct_member::Member::Field(field)) => {
1122 Some((field.name.clone(), i64::from(field.ordinal)))
1123 }
1124 _ => None,
1125 })
1126 .collect()
1127}
1128
1129fn struct_reserved(def: &v2::StructDef) -> Vec<i64> {
1130 def.members
1131 .iter()
1132 .filter_map(|member| match &member.member {
1133 Some(v2::struct_member::Member::Reserved(reserved)) => {
1134 Some(i64::from(reserved.ordinal))
1135 }
1136 _ => None,
1137 })
1138 .collect()
1139}
1140
1141pub(crate) fn union_slots(def: &v2::UnionDef) -> Vec<(String, i64)> {
1143 def.arms
1144 .iter()
1145 .map(|arm| (arm.name.clone(), i64::from(arm.ordinal)))
1146 .collect()
1147}
1148
1149fn union_reserved(def: &v2::UnionDef) -> Vec<i64> {
1150 def.reserved
1151 .iter()
1152 .map(|reserved| i64::from(reserved.ordinal))
1153 .collect()
1154}
1155
1156pub(crate) fn value_slots(values: &[v2::EnumValue]) -> Vec<(String, i64)> {
1159 values
1160 .iter()
1161 .map(|value| (value.name.clone(), value.value))
1162 .collect()
1163}
1164
1165fn reserved_values(reserved: &[v2::Reserved]) -> Vec<i64> {
1166 reserved.iter().filter_map(|entry| entry.value).collect()
1167}
1168
1169pub fn category_from_word(word: &str) -> Option<Category> {
1176 crate::CATEGORIES
1177 .into_iter()
1178 .find(|category| crate::category_word(*category) == word)
1179}
1180
1181#[deny(
1191 clippy::wildcard_enum_match_arm,
1192 clippy::match_wildcard_for_single_variants
1193)]
1194pub fn explain(category: Category) -> &'static str {
1195 match category {
1196 Category::DeclAdded => concat!(
1197 "A declaration, interface, or service present only in the new snapshot.\n",
1198 " compatible a new package-level decl, interface, or service; an enum\n",
1199 " value appended above every live and retired number; a\n",
1200 " union arm appended at the end of the body (typl 7.4,\n",
1201 " append-only); a struct field appended at the end of the body\n",
1202 " when it is optional, or when it is a scalar, enum, or enum set\n",
1203 " whose type allows the value 0\n",
1204 " breaking a member inserted below the highest slot ever used, a member\n",
1205 " taking a retired number, any addition that moves a surviving\n",
1206 " member's slot, or any arm of a result union (ADR-0008 d4); a\n",
1207 " non-optional struct field appended whose type does not allow\n",
1208 " 0 — a string, bytes, struct, union, tuple, array, or map, a\n",
1209 " scalar whose range or step excludes 0, an enum with no zero\n",
1210 " member, or a type the diff cannot resolve (ridl diff\n",
1211 " resolves ridl.std types). A reader of the new version\n",
1212 " refuses every payload of the old one, which does not carry\n",
1213 " the field (typl 7.4). Declare the new field optional instead\n",
1214 " (`field : T?`): an absent optional field reads as absent\n",
1215 " note an interface is matched by its interfaces.lock number (lock\n",
1216 " design 7): a frozen number the old side never held is a new\n",
1217 " interface, and so is every provisional one — a declaration\n",
1218 " with no lock entry carries no identity, and a rename keeps its\n",
1219 " number, so it is never a renamed interface (interface_renamed)"
1220 ),
1221 Category::DeclRemoved => concat!(
1222 "A declaration, interface, or service present only in the old snapshot.\n",
1223 " breaking a removed service, interface, decl, enum value, struct\n",
1224 " field, or union arm withdraws something a consumer compiled\n",
1225 " against\n",
1226 " note an interface is matched by its interfaces.lock number (lock\n",
1227 " design 7), so an interface here is one whose number is gone\n",
1228 " from the new side and not retired there — a lock line deleted\n",
1229 " by hand, or a number changed by hand, which is this row plus\n",
1230 " decl_added. A number the new side's lock retires is\n",
1231 " interface_retired instead, and `ridl baseline` refuses to\n",
1232 " publish the removal of a number it does not find retired\n",
1233 " (RIDL-412)\n",
1234 " caveat a composite member retired the sanctioned way — replaced by\n",
1235 " a `reserved` tombstone in its own slot (typl 7.4) — is also\n",
1236 " reported breaking today. The body comparison is keyed on\n",
1237 " member names and does not read the `reserved` list, so it\n",
1238 " cannot yet tell that retirement from a bare deletion. This\n",
1239 " errs on the safe side; carried as debt, see the note on\n",
1240 " `diff_composite`. The interaction-level tombstone IS\n",
1241 " recognised — see interaction_retired"
1242 ),
1243 Category::InterfaceRenamed => concat!(
1244 "An interface whose interfaces.lock number is the same on both sides and\n",
1245 "whose name changed.\n",
1246 " compatible always — the number is the interface's identity and its\n",
1247 " routing key (lock design 7, rsdl decision D-7), so nothing\n",
1248 " moves on the wire. Visible in source: a rename changes the\n",
1249 " generated identity-table names in both wire backends, which\n",
1250 " is why the text report lists it under the heading\n",
1251 " \"compatible on the wire, visible in source\". The path\n",
1252 " carries the new name and the detail old -> new; changes\n",
1253 " inside the interface are reported under the new name and\n",
1254 " classified as any other interface's\n",
1255 " note a provisional number is no identity: a declaration with no\n",
1256 " lock entry is never matched, so a rename the lock does not\n",
1257 " record is decl_removed plus decl_added, breaking, until\n",
1258 " `ridl lock <pkg> --rename Old=New` records it"
1259 ),
1260 Category::InterfaceRetired => concat!(
1261 "An interface whose number the old snapshot held is gone from the new\n",
1262 "snapshot, whose interfaces.lock retires it.\n",
1263 " compatible always — the sanctioned removal of an interface: the entry\n",
1264 " keeps its number forever with the word `retired`, so the\n",
1265 " number is never allocated again (lock design 4 and 7, rsdl\n",
1266 " decision D-7). A number gone with no retired entry is\n",
1267 " decl_removed, breaking, and `ridl baseline` refuses to publish\n",
1268 " it (RIDL-412)"
1269 ),
1270 Category::InterfaceFrozen => concat!(
1271 "An interface matched by name whose number is the same on both sides and\n",
1272 "whose provisional flag went from true to false: `ridl lock` froze the\n",
1273 "number in place.\n",
1274 " compatible always — the number is the routing key and it did not\n",
1275 " change, so nothing moves on the wire. The catalog hash\n",
1276 " changes, because it covers each interface's number and its\n",
1277 " provisional flag, and a generated face refuses a port whose\n",
1278 " catalog hash is another one (ADR-0023 decision 8): a peer\n",
1279 " built before the lock and a peer built after it refuse each\n",
1280 " other. This is why the text report lists it under the\n",
1281 " heading \"compatible on the wire, may change the catalog\n",
1282 " hash\".\n",
1283 " The detail carries the number before and after, marked\n",
1284 " (provisional) on the old side\n",
1285 " note a baseline never holds a provisional number (`ridl baseline`\n",
1286 " refuses one, RIDL-411), so this change is seen between two\n",
1287 " builds, or in a reversed comparison whose old side is a\n",
1288 " build. A number 0 on either side, from a snapshot written\n",
1289 " before the lock existed, is not compared"
1290 ),
1291 Category::InterfaceNumberChanged => concat!(
1292 "An interface matched across the two sides whose number differs.\n",
1293 " breaking always — the number is the interface's routing key, so a\n",
1294 " peer built against the old number cannot reach the\n",
1295 " interface at the new one. Seen between two provisional\n",
1296 " builds when a sibling interface added before this one in\n",
1297 " byte order moves its provisional number, and when `ridl\n",
1298 " lock` freezes a provisional number as another number. The\n",
1299 " detail carries the old and the new number, each marked\n",
1300 " (provisional) when it is\n",
1301 " note two frozen numbers are matched by number, so they never\n",
1302 " differ here: a frozen number changed by hand is\n",
1303 " decl_removed plus decl_added. A number 0 on either side,\n",
1304 " from a snapshot written before the lock existed, is not\n",
1305 " compared.\n",
1306 " Run `ridl lock` to freeze the numbers, so that a later\n",
1307 " sibling cannot move them"
1308 ),
1309 Category::MemberReordered => concat!(
1310 "A surviving composite member whose slot in the body changed.\n",
1311 " breaking always — a struct field or union arm takes its wire\n",
1312 " identity from its ordinal, its 1-based place in the body\n",
1313 " counting tombstones (typl 7.4), so a member whose ordinal\n",
1314 " changed has a new wire identity; the detail carries the old\n",
1315 " and new ordinal. An enum value or enum-set bit takes its\n",
1316 " identity from the explicit number it declares (typl 8, 9),\n",
1317 " so one whose number changed has a new wire identity; the\n",
1318 " detail carries the old and new value or bit. An enum or\n",
1319 " enum-set body reordered with no number changed is\n",
1320 " enum_reordered, compatible, instead\n",
1321 " note reported only when both bodies hold the same member names:\n",
1322 " a reorder in the same edit as an addition or a removal is\n",
1323 " reported through decl_added or decl_removed alone. A reorder\n",
1324 " in the same edit as an in-place change to the body is\n",
1325 " reported with constraint_changed on the container as well.\n",
1326 " An enum or enum-set body whose only differences are changed\n",
1327 " numbers, in any order, reports member_reordered alone"
1328 ),
1329 Category::EnumReordered => concat!(
1330 "An enum or enum-set body whose difference is only order: its values or\n",
1331 "bits reordered with no number changed, the enum's reserved list\n",
1332 "reordered with no entry added, removed or changed, or both.\n",
1333 " compatible always — a value or bit takes its identity from the\n",
1334 " explicit number it declares (typl 8, 9), so nothing moves\n",
1335 " on the wire. The catalog hash covers only the declarations\n",
1336 " an interface reaches. When an interface reaches the enum or\n",
1337 " enum set, the order of its values or bits, and of an enum's\n",
1338 " reserved list, is part of the hash, so the hash changes\n",
1339 " then, and a generated face refuses a port whose catalog hash\n",
1340 " is another one (ADR-0023 decision 8). This is why the text\n",
1341 " report lists it under the heading \"compatible on the wire,\n",
1342 " may change the catalog hash\". The path is the container's;\n",
1343 " there is no detail\n",
1344 " note a value or bit whose number changed is member_reordered,\n",
1345 " breaking, not enum_reordered"
1346 ),
1347 Category::InteractionAppended => concat!(
1348 "An interaction added after every slot that existed before.\n",
1349 " compatible the slot it takes was never occupied\n",
1350 " breaking the slot was freed by an untombstoned removal and is now\n",
1351 " reused by a new name — a reused wire identity (ADR-0008 d14)"
1352 ),
1353 Category::InteractionInserted => concat!(
1354 "An interaction added before the end of the interface body.\n",
1355 " breaking always — every later ordinal shifts, and the ordinal is the\n",
1356 " transport identity (ridl 11)"
1357 ),
1358 Category::InteractionReordered => concat!(
1359 "A surviving interaction whose relative order in the body changed.\n",
1360 " breaking always — a reorder shifts wire identities (ridl 11)"
1361 ),
1362 Category::InteractionRemoved => concat!(
1363 "An interaction removed without a `reserved` tombstone holding its slot.\n",
1364 " breaking always — the freed ordinal is reusable, so the wire identity\n",
1365 " is no longer permanent (ridl 11)"
1366 ),
1367 Category::InteractionRetired => concat!(
1368 "An interaction retired to a `reserved` tombstone in its own ordinal slot.\n",
1369 " compatible always — the sanctioned retirement: the slot stays occupied\n",
1370 " and every later ordinal holds (ridl 11)"
1371 ),
1372 Category::KindChanged => concat!(
1373 "An interaction whose kind changed (signal, event, command, query, fixed).\n",
1374 " breaking any direction — the kind selects the transport shape"
1375 ),
1376 Category::PayloadChanged => concat!(
1377 "A signal, event, or fixed payload type changed.\n",
1378 " breaking any direction, a stream added or removed included"
1379 ),
1380 Category::ReturnChanged => concat!(
1381 "A query return shape changed.\n",
1382 " breaking any direction — an ok-arm change, an error arm added, removed,\n",
1383 " or retyped, a stream added or removed, or any other change to\n",
1384 " the synthesized inline `T | E` transport identity (ADR-0008 d4:\n",
1385 " interface + interaction ordinal + ordered arm types)"
1386 ),
1387 Category::ParamsChanged => concat!(
1388 "A command or query parameter list changed.\n",
1389 " breaking any direction — a parameter added, removed, renamed, retyped,\n",
1390 " or a stream added or removed on one"
1391 ),
1392 Category::TimingChanged => concat!(
1393 "A signal or event resolved timing changed (ADR-0008 d12).\n",
1394 " compatible min raised (a higher rate floor) or max lowered (a tighter\n",
1395 " staleness bound) with the mode unchanged; default_applied\n",
1396 " flipped over identical resolved bounds — a default made\n",
1397 " explicit\n",
1398 " breaking min lowered, max raised, a bound added where none was, a bound\n",
1399 " removed, or the strict-periodic/range mode flipped\n",
1400 " note editing `[defaults].timing` needs no special rule: diff\n",
1401 " compares resolved bounds, so it surfaces here on every\n",
1402 " defaulted interaction (ridl 9.1)"
1403 ),
1404 Category::RpcBoundChanged => concat!(
1405 "A command or query declared RPC bound changed (ADR-0015 d8).\n",
1406 " compatible min lowered (the caller may call more often) or max lowered\n",
1407 " (a stronger provider promise), with the mode unchanged;\n",
1408 " default_applied flipped over identical resolved bounds — a\n",
1409 " default made explicit\n",
1410 " breaking min raised — on an RPC, min is the call throttle and\n",
1411 " constrains the caller, so raising it withdraws a call rate\n",
1412 " the caller was entitled to use; max raised (a weaker provider\n",
1413 " promise); a bound added or removed, the whole annotation\n",
1414 " included\n",
1415 " note the min direction is the inverse of timing_changed's, which\n",
1416 " is why this is a category of its own rather than a branch:\n",
1417 " a missed branch would inherit the signal rule and call a\n",
1418 " raised RPC min compatible (ADR-0012 d9, fail closed).\n",
1419 " an RPC bound may come from a default, and diff compares\n",
1420 " resolved bounds, so editing a default surfaces here on\n",
1421 " every defaulted command or query (ridl 9.1)"
1422 ),
1423 Category::LimitChanged => concat!(
1424 "The payload limit of the interaction changed; every change of a limit is\n",
1425 "breaking, ADR-0030 decision 13.\n",
1426 " breaking a limit raised, lowered, added where none was, or removed"
1427 ),
1428 Category::ContractChanged => concat!(
1429 "A command or query require/ensure clause set changed (ridl 13).\n",
1430 " compatible a require removed, or an ensure added\n",
1431 " breaking a require added or its text changed, or an ensure removed or\n",
1432 " its text changed. Clause text is compared verbatim: the\n",
1433 " classifier does not prove that one clause implies another\n",
1434 " (ADR-0008 d14)"
1435 ),
1436 Category::InteractionClassChanged => concat!(
1437 "A name-paired interaction's family or operation shape changed\n",
1438 "(ADR-0012 d6).\n",
1439 " breaking any change, in either direction: the family selects the\n",
1440 " obligations a consumer compiled against, and the shape\n",
1441 " the gesture an intent command is. No source writes a\n",
1442 " family other than dispatch yet; the walk compares the\n",
1443 " fields from the start so the diff fails closed (ADR-0012 d9)"
1444 ),
1445 Category::WidthChanged => concat!(
1446 "A derived wire width or scalar backing changed.\n",
1447 " breaking any IntWidth or FloatWidth change, uint64 versus int64\n",
1448 " included — the resolved width is part of the contract\n",
1449 " (typl 4.2, 5.6)"
1450 ),
1451 Category::ConstraintChanged => concat!(
1452 "A scalar constraint changed, or a composite body changed in place.\n",
1453 " compatible widened — min lowered without shifting a stepped grid, max\n",
1454 " raised, a length bound loosened,\n",
1455 " a step removed, a match pattern removed (by literal or by\n",
1456 " named constant), or the whole constraint dropped so the\n",
1457 " value is unbounded again\n",
1458 " breaking narrowed — min raised, max lowered, a length bound\n",
1459 " tightened, a stepped grid shifted, a step added or changed\n",
1460 " (divisibility between different steps is never\n",
1461 " proved), a match pattern added or rewritten (by literal or\n",
1462 " by named constant), or a constraint appearing where there\n",
1463 " was none, which bounds a previously unbounded value. A\n",
1464 " composite body changed in place is breaking: the walk\n",
1465 " does not say which member changed inside it\n",
1466 " note each facet is judged on its own and any one narrowing\n",
1467 " decides the change, so a mixed edit is breaking on the half\n",
1468 " that narrows. A widening that flips the resolved wire width\n",
1469 " is separately reported as width_changed, which is always\n",
1470 " breaking (typl 5.6)"
1471 ),
1472 Category::InitChanged => concat!(
1473 "A declared or resolved init value changed.\n",
1474 " breaking any direction — the init is the value a consumer reads before\n",
1475 " the first publish, so it is part of the contract (ridl 9.1)"
1476 ),
1477 Category::ReservedNameRedeclared => concat!(
1478 "A name retired by a `reserved` tombstone is live again — an interaction\n",
1479 "inside an interface body.\n",
1480 " breaking always — a retired identity is never reused (ridl 11,\n",
1481 " RIDL-401)"
1482 ),
1483 Category::ServiceChanged => concat!(
1484 "A service switched between the named list and an inline shape.\n",
1485 " breaking always — extraction rewrites the transport identity of every\n",
1486 " fallible query in the shape: an inline shape derives it from\n",
1487 " the service's dotted name, a named interface from its own name\n",
1488 " (ADR-0008 d4, ADR-0015 d15). A changed list is not this\n",
1489 " category: it is read as a set by the service_interface_*\n",
1490 " rows (ADR-0015 d19, as amended 2026-09-15)"
1491 ),
1492 Category::ServiceInterfaceAdded => concat!(
1493 "An interface joined a service's set of interfaces.\n",
1494 " compatible always — nothing that existed moved: an interface's number\n",
1495 " comes from its unit's interfaces.lock, not from its place\n",
1496 " in the list, and the routing key does not contain the service\n",
1497 " (ADR-0015 d19, as amended 2026-09-15)"
1498 ),
1499 Category::ServiceInterfaceRemoved => concat!(
1500 "An interface left a service's set of interfaces.\n",
1501 " compatible always — on the wire: the routing key does not contain the\n",
1502 " service, so no identity moves, and a split into two services\n",
1503 " is a removal plus an addition. Visible in source: the\n",
1504 " service.member addresses of that interface stop resolving\n",
1505 " under this service, which is why the text report lists it\n",
1506 " under the heading \"compatible on the wire, visible in\n",
1507 " source\". A consumer that loses its only provider is a\n",
1508 " wiring error for rsdl, not a package diff (ADR-0015 d19, as\n",
1509 " amended 2026-09-15)"
1510 ),
1511 Category::DocOnly => concat!(
1512 "Only doc comment, labels, or deprecation metadata changed.\n",
1513 " compatible always — none of it reaches a consumer's build.\n",
1514 " Visibility is NOT in this category: see\n",
1515 " visibility_changed"
1516 ),
1517 Category::VisibilityChanged => concat!(
1518 "The visibility a declaration is published at changed.\n",
1519 " compatible internal -> public: the declaration is offered to more\n",
1520 " consumers than before\n",
1521 " breaking public -> internal: `internal` maps to the target's\n",
1522 " package-private mechanism — Rust `pub(crate)`, a\n",
1523 " non-exported TypeScript member (ADR-0002 8) — so the\n",
1524 " declaration disappears from every out-of-package\n",
1525 " consumer's build. The wire layout does not move, but the\n",
1526 " consumer-visible guarantee narrows (ADR-0008 d14). Any\n",
1527 " direction involving an unset visibility is breaking"
1528 ),
1529 Category::PodbufRemoteInlineGrowth => concat!(
1530 "A remote declaration held inline by an old consumer has a larger podbuf size.\n",
1531 "The path belongs to the consumer; the values name the remote declaration\n",
1532 "and its old and new inline sizes. An inline member has no nested producer-size\n",
1533 "boundary, so its growth can move the fields after it even when only a remote\n",
1534 "pin changes and the local consumer declaration is unchanged. Producer-sized\n",
1535 "roots, array elements, map entries and union arms may grow, but their inline\n",
1536 "members may not. An append that fits entirely in the old head's pad bytes\n",
1537 "keeps the head size and draws no line; verify does not check pads.\n",
1538 "Shrinkage, other equal-size layout changes and general dependency semantic\n",
1539 "changes are deferred; this category judges only remote inline growth.\n",
1540 " breaking with `--encoding podbuf`\n",
1541 " compatible otherwise; listed under the heading \"breaking under podbuf\""
1542 ),
1543 Category::PodbufInlineAppend => concat!(
1544 "A struct field appended, compatible under the generic rule, to a\n",
1545 "struct another head holds inline on the old side — as a struct field,\n",
1546 "a tuple member, or an element of an exact-length array, in a struct,\n",
1547 "in the argument struct a call with several parameters induces, or in\n",
1548 "a tuple reply — when the field does not fit entirely inside the old\n",
1549 "head: the first-fit law places any of its bytes past the old head's\n",
1550 "size, so that the head grows. A field the law places entirely inside\n",
1551 "the old head, in its pad bytes, draws no line, because `verify` does\n",
1552 "not check pads; a field that starts in the pads and ends past the\n",
1553 "old size draws it. When several fields are appended at once, each is\n",
1554 "judged by its own bytes. Only a holder of the old side counts, because\n",
1555 "a holder new on the new side has no published copy;\n",
1556 "the detail names each inline use of the old side. The search mirrors\n",
1557 "the heads the encoder lays out, a declaration and a named induced\n",
1558 "tuple: a struct that is a root of its own (a single named parameter, a\n",
1559 "named reply, a signal, event or fixed payload), that is used only\n",
1560 "behind a descriptor (the element of a bounded array, the key or value\n",
1561 "of a map entry, a union arm), or only inside an exact-length array\n",
1562 "that is itself a root (a fixed payload `[S; 2..2]`, which has no head)\n",
1563 "is not reported: the root or the stride gives the producer's size, and\n",
1564 "the fields past it read as 0. A tuple inside such a root is a head,\n",
1565 "and a struct inside that tuple is a use.\n",
1566 " breaking with `--encoding podbuf`: the inline copy cannot grow\n",
1567 " without moving the fields after it\n",
1568 " compatible otherwise; listed under the heading \"breaking under\n",
1569 " podbuf\", so that a workspace that builds podbuf sees it"
1570 ),
1571 Category::PodbufWidthChange => concat!(
1572 "An enum value, an enum-set flag or a union arm appended, compatible\n",
1573 "under the generic rule, after which the podbuf integer of the enum or\n",
1574 "the enum set or the tag of the union is a different integer, by width\n",
1575 "or by signedness. podbuf lays an enum out as the narrowest integer\n",
1576 "that holds every declared value, signed when a value is negative\n",
1577 "(ADR-0031 decision 5), an enum set as its width, the narrowest\n",
1578 "unsigned integer that holds its highest bit, and a union's tag as the\n",
1579 "narrowest unsigned integer that holds the largest arm ordinal: a value\n",
1580 "above 255, a flag at bit 8 or an arm past ordinal 255 widens a `u8` to\n",
1581 "a `u16`, a value above 65535 or a flag at bit 16 a `u16` to a `u32`,\n",
1582 "and a value above 4294967295 or a flag at bit 32 a `u32` to a `u64`.\n",
1583 "A negative value appended to an enum that has values is below its\n",
1584 "highest value, an insert, which the generic rule reports as a breaking\n",
1585 "`decl_added` line and not as a width line; the integer turns signed\n",
1586 "only when an enum with no value gains values (`-1` gives an `i8`,\n",
1587 "`-1` and `200` an `i16`). The line is reported once per declaration\n",
1588 "and carries the old and the new integer. An append that keeps the\n",
1589 "integer draws no such line.\n",
1590 " breaking with `--encoding podbuf`: every head that holds the enum,\n",
1591 " the enum set or the union lays out a different integer,\n",
1592 " and an older reader misreads the value\n",
1593 " compatible otherwise; listed under the\n",
1594 " heading \"breaking under podbuf\", so that a workspace\n",
1595 " that builds podbuf sees it. An enum-set flag that widens\n",
1596 " the integer also widens the enum set's FlatBuffers\n",
1597 " scalar, which `flatbuffers_width_change` reports, and a\n",
1598 " flag at bit 32 also widens the proto3 field, which\n",
1599 " `proto3_width_change` reports"
1600 ),
1601 Category::PodbufStrictEnumAppend => concat!(
1602 "Superseded by `strict_enum_append`, which is breaking for every\n",
1603 "encoding. `ridl diff` no longer reports this category; the word stays\n",
1604 "so that a report or a script that names it still reads.\n",
1605 " breaking with `--encoding podbuf`\n",
1606 " compatible otherwise"
1607 ),
1608 Category::StrictEnumAppend => concat!(
1609 "An enum value or an enum-set flag appended to an enum or an enum set\n",
1610 "that is not declared `open`. A reader built from the old declaration\n",
1611 "refuses a value or a flag it does not name: the `verify` of podbuf\n",
1612 "does, and so does the codec of every encoding that checks the\n",
1613 "declaration, so the append is breaking whatever encodings the\n",
1614 "comparison is judged for. To let readers accept values a later\n",
1615 "version adds, declare an open enum or an open enum set (the `open`\n",
1616 "keyword) before the first release that readers depend on: an append\n",
1617 "to an open enum or an open enum set is compatible. The line is reported once per\n",
1618 "declaration, at `<package>/<decl>`, and the detail names the\n",
1619 "appended values or flags.\n",
1620 " breaking always, for every encoding\n",
1621 " note a value or flag that is not an append (an insert below\n",
1622 " the highest number, or a reused number) is decl_added,\n",
1623 " breaking, instead"
1624 ),
1625 Category::EnumOpened => concat!(
1626 "An enum or an enum set that gained the `open` keyword. A reader built\n",
1627 "from the old declaration already refuses nothing that the new one\n",
1628 "names, and a reader built from the new declaration accepts any\n",
1629 "integer, so no producer or reader of either side is refused by the\n",
1630 "other. The path is the declaration's; there is no detail.\n",
1631 " compatible always"
1632 ),
1633 Category::EnumClosed => concat!(
1634 "An enum or an enum set that lost the `open` keyword. A reader built\n",
1635 "from the new declaration refuses a value that a producer of the old\n",
1636 "declaration may still send, because the old declaration accepted any\n",
1637 "integer. The path is the declaration's; there is no detail.\n",
1638 " breaking always"
1639 ),
1640 Category::FlatbuffersWidthChange => concat!(
1641 "An enum value or an enum-set flag appended, compatible under the\n",
1642 "generic rule, after which the FlatBuffers integer of the enum or the\n",
1643 "enum set is a different integer. FlatBuffers stores an enum set as the\n",
1644 "unsigned scalar of its width, the narrowest that holds its highest\n",
1645 "bit: a flag at bit 8 widens a `u8` (`ubyte`) to a `u16` (`ushort`),\n",
1646 "a flag at bit 16 a `u16` to a `u32` (`uint`), and a flag at bit 32 a\n",
1647 "`u32` to a `u64` (`ulong`). Every enum is stored as `long` whatever\n",
1648 "its values, so an appended enum value keeps the integer; a union's\n",
1649 "type field is always `ubyte`. The line is reported once per\n",
1650 "declaration and carries the old and the new integer. An append that\n",
1651 "keeps the integer draws no such line.\n",
1652 "Only per-side context entry points report a remote declaration reached\n",
1653 "by a surviving old consumer use once per consumer path and remote\n",
1654 "declaration, at the consumer path with `<remote declaration>: <integer>`\n",
1655 "values; a side without remote context draws no such line.\n",
1656 " breaking with `--encoding flatbuffers`: every table field and\n",
1657 " vector element of the type changes its scalar, so a\n",
1658 " reader of the new schema rejects or misreads an old\n",
1659 " buffer and a reader of the old schema drops the flag\n",
1660 " compatible otherwise; listed under the heading \"breaking under\n",
1661 " flatbuffers\", so that a workspace that builds\n",
1662 " FlatBuffers sees it"
1663 ),
1664 Category::Proto3WidthChange => concat!(
1665 "An enum-set flag appended, compatible under the generic rule, after\n",
1666 "which the proto3 scalar of the enum set is a different scalar. proto3\n",
1667 "has no 8-bit or 16-bit integer, so an enum set of width `u8`, `u16`\n",
1668 "or `u32` is a `uint32` field and one of width `u64` is a `uint64`\n",
1669 "field: a flag at bit 32 above a highest bit of 31 or below widens\n",
1670 "`uint32` to `uint64`, and a flag at bit 8 or 16 keeps `uint32`. Every\n",
1671 "enum is a proto3 `enum`, an `int32`, and the proto3 backend refuses a\n",
1672 "value outside `int32`, so an appended enum value never changes it.\n",
1673 "The line is reported once per declaration and carries the old and the\n",
1674 "new scalar. An append that keeps the scalar draws no such line.\n",
1675 "Only per-side context entry points report a remote declaration reached\n",
1676 "by a surviving old consumer use once per consumer path and remote\n",
1677 "declaration, at the consumer path with `<remote declaration>: <integer>`\n",
1678 "values; a side without remote context draws no such line.\n",
1679 " breaking with `--encoding proto3`: a reader of the old schema\n",
1680 " refuses the value (the generated Rust codec) or truncates\n",
1681 " it to 32 bits and drops the flag\n",
1682 " compatible otherwise; listed under the heading \"breaking under\n",
1683 " proto3\", so that a workspace that builds proto3 sees it"
1684 ),
1685 }
1686}