1use ridl_ir::v2;
26
27use crate::{Category, Change, Verdict, frozen};
28
29#[cfg(test)]
30mod classify_tests;
31
32pub fn classify(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
45 classify_in(change, old, new, &[])
46}
47
48#[deny(
60 clippy::wildcard_enum_match_arm,
61 clippy::match_wildcard_for_single_variants
62)]
63pub(crate) fn classify_in(
64 change: &Change,
65 old: &v2::Package,
66 new: &v2::Package,
67 scope: &[&v2::Package],
68) -> Verdict {
69 match change.category {
70 Category::InteractionInserted
73 | Category::InteractionReordered
74 | Category::MemberReordered
75 | Category::InteractionRemoved
76 | Category::ReservedNameRedeclared
77 | Category::KindChanged
78 | Category::PayloadChanged
79 | Category::ReturnChanged
80 | Category::ParamsChanged
81 | Category::WidthChanged
82 | Category::ServiceChanged
83 | Category::DeclRemoved => Verdict::Breaking,
84
85 Category::InitChanged => Verdict::Breaking,
89
90 Category::DocOnly | Category::InteractionRetired => Verdict::Compatible,
97
98 Category::InterfaceRenamed | Category::InterfaceRetired => Verdict::Compatible,
105
106 Category::InterfaceFrozen => Verdict::Compatible,
112 Category::InterfaceNumberChanged => Verdict::Breaking,
113
114 Category::LimitChanged => Verdict::Breaking,
118
119 Category::EnumReordered => Verdict::Compatible,
125
126 Category::ServiceInterfaceAdded | Category::ServiceInterfaceRemoved => Verdict::Compatible,
134
135 Category::VisibilityChanged => visibility(change, old, new),
136 Category::InteractionAppended => appended(change, old, new),
137 Category::DeclAdded => added(change, old, new, scope),
138 Category::ConstraintChanged => constraint(change, old, new),
139 Category::TimingChanged => timing(change, old, new),
140 Category::RpcBoundChanged => rpc_bound(change, old, new),
141 Category::ContractChanged => contract(change, old, new),
142 Category::InteractionClassChanged => Verdict::Breaking,
146 }
147}
148
149fn visibility(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
167 let (Some(old_visibility), Some(new_visibility)) = (
168 find_visibility(old, &change.path, |name| find_old_shape(old, new, name)),
169 find_visibility(new, &change.path, |name| find_shape(new, name)),
170 ) else {
171 return Verdict::Breaking;
172 };
173
174 match (
175 v2::Visibility::try_from(old_visibility),
176 v2::Visibility::try_from(new_visibility),
177 ) {
178 (Ok(v2::Visibility::Internal), Ok(v2::Visibility::Public)) => Verdict::Compatible,
179 _ => Verdict::Breaking,
180 }
181}
182
183fn find_visibility<'a>(
190 package: &'a v2::Package,
191 path: &str,
192 shape_of: impl Fn(&str) -> Option<v2::InterfaceShape<'a>>,
193) -> Option<i32> {
194 let mut segments = path.split('/').skip(1);
195 let name = segments.next()?;
196
197 if let Some(member) = segments.next() {
198 return shape_of(name)?
199 .interface
200 .interactions
201 .iter()
202 .find(|decl| decl.name == member)
203 .map(|decl| decl.visibility);
204 }
205 if let Some(decl) = find_decl(package, name) {
206 return Some(decl.visibility);
207 }
208 if let Some(shape) = shape_of(name) {
213 return Some(shape.visibility());
214 }
215 package
218 .services
219 .iter()
220 .find(|service| service.name == name)
221 .map(|service| service.visibility)
222}
223
224fn appended(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
239 let Some((container, member)) = member_path(change) else {
240 return Verdict::Breaking;
241 };
242 let (Some(old_iface), Some(new_iface)) = (
243 find_old_interface(old, new, container),
244 find_interface(new, container),
245 ) else {
246 return Verdict::Breaking;
247 };
248 let Some(ordinal) = slot_ordinal(new_iface, member) else {
249 return Verdict::Breaking;
250 };
251
252 for (name, old_ordinal) in slots(old_iface) {
253 if old_ordinal == ordinal && name != member {
254 return Verdict::Breaking;
255 }
256 }
257 Verdict::Compatible
258}
259
260fn added(change: &Change, old: &v2::Package, new: &v2::Package, scope: &[&v2::Package]) -> Verdict {
276 let Some((container, member)) = member_path(change) else {
277 return Verdict::Compatible;
280 };
281 let (Some(old_decl), Some(new_decl)) = (find_decl(old, container), find_decl(new, container))
282 else {
283 return Verdict::Breaking;
284 };
285
286 use v2::decl::Kind;
291 let appended = match (&old_decl.kind, &new_decl.kind) {
292 (Some(Kind::StructDef(old_def)), Some(Kind::StructDef(new_def))) => {
293 appended_slot(
294 &struct_slots(old_def),
295 &struct_reserved(old_def),
296 &struct_slots(new_def),
297 ) && absence_reads_as_legal(new_def, member, new, scope)
298 }
299 (Some(Kind::UnionDef(old_def)), Some(Kind::UnionDef(new_def))) => {
300 !old_def.is_result
303 && !new_def.is_result
304 && appended_slot(
305 &union_slots(old_def),
306 &union_reserved(old_def),
307 &union_slots(new_def),
308 )
309 }
310 (Some(Kind::EnumDef(old_def)), Some(Kind::EnumDef(new_def))) => appended_slot(
311 &value_slots(&old_def.values),
312 &reserved_values(&old_def.reserved),
313 &value_slots(&new_def.values),
314 ),
315 (Some(Kind::EnumSetDef(old_def)), Some(Kind::EnumSetDef(new_def))) => appended_slot(
316 &value_slots(&old_def.bits),
317 &[],
318 &value_slots(&new_def.bits),
319 ),
320 _ => false,
322 };
323
324 if appended {
325 Verdict::Compatible
326 } else {
327 Verdict::Breaking
328 }
329}
330
331pub fn absence_refused(
340 new_set: &[v2::Package],
341 context: &[v2::Package],
342 package: &str,
343 container: &str,
344 member: &str,
345) -> bool {
346 let scope: Vec<&v2::Package> = new_set.iter().chain(context).collect();
347 let Some(home) = new_set.iter().find(|candidate| candidate.name == package) else {
348 return false;
349 };
350 match find_decl(home, container).and_then(|decl| decl.kind.as_ref()) {
351 Some(v2::decl::Kind::StructDef(def)) => !absence_reads_as_legal(def, member, home, &scope),
352 _ => false,
353 }
354}
355
356fn absence_reads_as_legal(
376 def: &v2::StructDef,
377 member: &str,
378 home: &v2::Package,
379 scope: &[&v2::Package],
380) -> bool {
381 let Some(r#type) = def.members.iter().find_map(|slot| match &slot.member {
382 Some(v2::struct_member::Member::Field(field)) if field.name == member => {
383 field.r#type.as_ref()
384 }
385 _ => None,
386 }) else {
387 return false;
388 };
389 if r#type.optional {
390 return true;
391 }
392 use v2::field_type::Kind;
393 match &r#type.kind {
394 Some(Kind::Primitive(primitive)) => matches!(
395 v2::PrimitiveType::try_from(*primitive),
396 Ok(v2::PrimitiveType::Boolean | v2::PrimitiveType::Integer | v2::PrimitiveType::Float)
397 ),
398 Some(Kind::InlineScalar(def)) => scalar_holds_zero(def),
399 Some(Kind::Named(reference)) => {
400 use v2::decl::Kind as Decl;
401 match resolve(home, scope, reference).and_then(|decl| decl.kind.as_ref()) {
402 Some(Decl::TypeDef(def)) => scalar_holds_zero(def),
403 Some(Decl::EnumDef(def)) => ridl_ir::zero::enum_zero_member(&def.values).is_some(),
404 Some(Decl::EnumSetDef(_)) => true,
406 _ => false,
407 }
408 }
409 _ => false,
410 }
411}
412
413fn scalar_holds_zero(def: &v2::TypeDef) -> bool {
417 let numeric = match def
418 .backing
419 .as_ref()
420 .and_then(|backing| backing.kind.as_ref())
421 {
422 Some(v2::backing::Kind::Unit(_)) => true,
423 Some(v2::backing::Kind::Primitive(primitive)) => matches!(
424 v2::PrimitiveType::try_from(*primitive),
425 Ok(v2::PrimitiveType::Boolean | v2::PrimitiveType::Integer | v2::PrimitiveType::Float)
426 ),
427 None => false,
428 };
429 numeric
430 && def.constraint.as_ref().is_none_or(|constraint| {
431 ridl_ir::zero::range_holds_zero(
432 constraint.min.as_deref(),
433 constraint.max.as_deref(),
434 constraint.step.as_deref(),
435 )
436 })
437}
438
439fn resolve<'a>(
443 home: &'a v2::Package,
444 scope: &[&'a v2::Package],
445 reference: &str,
446) -> Option<&'a v2::Decl> {
447 match reference.rsplit_once('.') {
448 Some((package, name)) => std::iter::once(home)
449 .chain(scope.iter().copied())
450 .find(|candidate| candidate.name == package)
451 .and_then(|candidate| find_decl(candidate, name)),
452 None => find_decl(home, reference),
453 }
454}
455
456fn appended_slot(old: &[(String, i64)], old_retired: &[i64], new: &[(String, i64)]) -> bool {
467 for (name, old_slot) in old {
468 match new.iter().find(|(new_name, _)| new_name == name) {
469 Some((_, new_slot)) if new_slot == old_slot => {}
470 _ => return false,
472 }
473 }
474
475 let held = || {
476 old.iter()
477 .map(|(_, slot)| *slot)
478 .chain(old_retired.iter().copied())
479 };
480 let old_names: Vec<&String> = old.iter().map(|(name, _)| name).collect();
481 for (name, slot) in new {
482 if old_names.contains(&name) {
483 continue;
484 }
485 if !is_above_high_water(*slot, held()) {
486 return false;
487 }
488 }
489 true
490}
491
492pub fn is_above_high_water(slot: i64, held: impl IntoIterator<Item = i64>) -> bool {
501 held.into_iter().max().is_none_or(|mark| slot > mark)
502}
503
504fn constraint(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
516 let mut segments = change.path.split('/').skip(1);
517 let (Some(name), None) = (segments.next(), segments.next()) else {
518 return Verdict::Breaking;
519 };
520 let (Some(old_decl), Some(new_decl)) = (find_decl(old, name), find_decl(new, name)) else {
521 return Verdict::Breaking;
522 };
523
524 use v2::decl::Kind;
525 let (Some(Kind::TypeDef(old_def)), Some(Kind::TypeDef(new_def))) =
526 (&old_decl.kind, &new_decl.kind)
527 else {
528 return Verdict::Breaking;
529 };
530
531 if narrows(old_def.constraint.as_ref(), new_def.constraint.as_ref()) {
532 Verdict::Breaking
533 } else {
534 Verdict::Compatible
535 }
536}
537
538fn narrows(old: Option<&v2::Constraint>, new: Option<&v2::Constraint>) -> bool {
545 let (Some(old), Some(new)) = (old, new) else {
546 return old.is_none() && new.is_some();
549 };
550
551 if raised(old.min.as_deref(), new.min.as_deref())
553 || lowered(old.max.as_deref(), new.max.as_deref())
554 {
555 return true;
556 }
557 if raised_u64(old.len_min, new.len_min) || lowered_u64(old.len_max, new.len_max) {
559 return true;
560 }
561 if new.step.is_some() && old.step != new.step {
564 return true;
565 }
566 if let Some(step) = new.step.as_deref()
569 && old.min != new.min
570 && !same_grid(old.min.as_deref(), new.min.as_deref(), step)
571 {
572 return true;
573 }
574 if (new.pattern.is_some() && old.pattern != new.pattern)
577 || (new.pattern_const.is_some() && old.pattern_const != new.pattern_const)
578 {
579 return true;
580 }
581 false
582}
583
584fn same_grid(old: Option<&str>, new: Option<&str>, step: &str) -> bool {
588 use num_bigint::BigInt;
589
590 let read = |text: &str| -> Option<(BigInt, usize)> {
591 let (negative, digits) = split_sign(text)?;
592 let (whole, fraction) = digits.split_once('.').unwrap_or((&digits, ""));
593 if whole.is_empty() || (digits.contains('.') && fraction.is_empty()) {
594 return None;
595 }
596 let coefficient = BigInt::parse_bytes(format!("{whole}{fraction}").as_bytes(), 10)?;
597 Some((
598 if negative { -coefficient } else { coefficient },
599 fraction.len(),
600 ))
601 };
602 let (Some(old), Some(new), Some(step)) = (
603 read(old.unwrap_or("0")),
604 read(new.unwrap_or("0")),
605 read(step),
606 ) else {
607 return false;
608 };
609 let scale = old.1.max(new.1).max(step.1);
610 let rescale = |(coefficient, places): (BigInt, usize)| -> Option<BigInt> {
611 Some(coefficient * BigInt::from(10).pow(u32::try_from(scale - places).ok()?))
612 };
613 let (Some(old), Some(new), Some(step)) = (rescale(old), rescale(new), rescale(step)) else {
614 return false;
615 };
616 step > BigInt::from(0) && (old - new) % step == BigInt::from(0)
617}
618
619fn raised(old: Option<&str>, new: Option<&str>) -> bool {
621 match (old, new) {
622 (None, Some(_)) => true,
623 (Some(old), Some(new)) => cmp_decimal(new, old).is_none_or(std::cmp::Ordering::is_gt),
624 _ => false,
625 }
626}
627
628fn lowered(old: Option<&str>, new: Option<&str>) -> bool {
630 match (old, new) {
631 (None, Some(_)) => true,
632 (Some(old), Some(new)) => cmp_decimal(new, old).is_none_or(std::cmp::Ordering::is_lt),
633 _ => false,
634 }
635}
636
637fn raised_u64(old: Option<u64>, new: Option<u64>) -> bool {
638 match (old, new) {
639 (None, Some(_)) => true,
640 (Some(old), Some(new)) => new > old,
641 _ => false,
642 }
643}
644
645fn lowered_u64(old: Option<u64>, new: Option<u64>) -> bool {
646 match (old, new) {
647 (None, Some(_)) => true,
648 (Some(old), Some(new)) => new < old,
649 _ => false,
650 }
651}
652
653fn cmp_decimal(left: &str, right: &str) -> Option<std::cmp::Ordering> {
663 use std::cmp::Ordering;
664
665 let (left_negative, left_digits) = split_sign(left)?;
666 let (right_negative, right_digits) = split_sign(right)?;
667 if left_negative != right_negative {
668 return Some(if left_negative {
671 Ordering::Less
672 } else {
673 Ordering::Greater
674 });
675 }
676
677 let magnitude = cmp_magnitude(&left_digits, &right_digits)?;
678 Some(if left_negative {
679 magnitude.reverse()
680 } else {
681 magnitude
682 })
683}
684
685fn split_sign(text: &str) -> Option<(bool, String)> {
688 let (negative, rest) = match text.strip_prefix('-') {
689 Some(rest) => (true, rest),
690 None => (false, text.strip_prefix('+').unwrap_or(text)),
691 };
692 if rest.is_empty() || !rest.chars().all(|c| c.is_ascii_digit() || c == '.') {
693 return None;
694 }
695 if rest.matches('.').count() > 1 {
696 return None;
697 }
698 Some((negative, rest.to_string()))
699}
700
701fn cmp_magnitude(left: &str, right: &str) -> Option<std::cmp::Ordering> {
704 use std::cmp::Ordering;
705
706 let (left_int, left_frac) = left.split_once('.').unwrap_or((left, ""));
707 let (right_int, right_frac) = right.split_once('.').unwrap_or((right, ""));
708
709 let left_int = left_int.trim_start_matches('0');
710 let right_int = right_int.trim_start_matches('0');
711 let by_length = left_int.len().cmp(&right_int.len());
712 if by_length != Ordering::Equal {
713 return Some(by_length);
714 }
715 let by_int = left_int.cmp(right_int);
716 if by_int != Ordering::Equal {
717 return Some(by_int);
718 }
719
720 let width = left_frac.len().max(right_frac.len());
722 let pad = |frac: &str| format!("{frac:0<width$}");
723 Some(pad(left_frac).cmp(&pad(right_frac)))
724}
725
726fn timing(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
738 let Some((container, member)) = member_path(change) else {
739 return Verdict::Breaking;
740 };
741 let (Some(old_decl), Some(new_decl)) = (
742 find_old_interaction(old, new, container, member),
743 find_interaction(new, container, member),
744 ) else {
745 return Verdict::Breaking;
746 };
747 let (Some(old_timing), Some(new_timing)) =
748 (interaction_timing(old_decl), interaction_timing(new_decl))
749 else {
750 return Verdict::Breaking;
760 };
761
762 let (Some(old_timing), Some(new_timing)) = (old_timing, new_timing) else {
763 return Verdict::Breaking;
766 };
767
768 if old_timing.mode != new_timing.mode {
769 return Verdict::Breaking;
770 }
771 if lowered(old_timing.min_us.as_deref(), new_timing.min_us.as_deref())
773 || dropped(old_timing.min_us.as_deref(), new_timing.min_us.as_deref())
774 || raised(old_timing.max_us.as_deref(), new_timing.max_us.as_deref())
775 || dropped(old_timing.max_us.as_deref(), new_timing.max_us.as_deref())
776 {
777 return Verdict::Breaking;
778 }
779 Verdict::Compatible
782}
783
784fn rpc_bound(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
803 let Some((container, member)) = member_path(change) else {
804 return Verdict::Breaking;
805 };
806 let (Some(old_decl), Some(new_decl)) = (
807 find_old_interaction(old, new, container, member),
808 find_interaction(new, container, member),
809 ) else {
810 return Verdict::Breaking;
811 };
812 let (Some(old_timing), Some(new_timing)) = (rpc_timing(old_decl), rpc_timing(new_decl)) else {
813 return Verdict::Breaking;
820 };
821
822 let (Some(old_timing), Some(new_timing)) = (old_timing, new_timing) else {
823 return Verdict::Breaking;
826 };
827
828 if old_timing.mode != new_timing.mode {
831 return Verdict::Breaking;
832 }
833 if raised(old_timing.min_us.as_deref(), new_timing.min_us.as_deref())
838 || dropped(old_timing.min_us.as_deref(), new_timing.min_us.as_deref())
839 || raised(old_timing.max_us.as_deref(), new_timing.max_us.as_deref())
840 || dropped(old_timing.max_us.as_deref(), new_timing.max_us.as_deref())
841 {
842 return Verdict::Breaking;
843 }
844 Verdict::Compatible
850}
851
852fn dropped(old: Option<&str>, new: Option<&str>) -> bool {
854 old.is_some() && new.is_none()
855}
856
857fn interaction_timing(decl: &v2::Decl) -> Option<Option<&v2::Timing>> {
860 use v2::decl::Kind;
861 match &decl.kind {
862 Some(Kind::SignalDef(def)) => Some(def.timing.as_ref()),
863 Some(Kind::EventDef(def)) => Some(def.timing.as_ref()),
864 _ => None,
865 }
866}
867
868fn rpc_timing(decl: &v2::Decl) -> Option<Option<&v2::Timing>> {
872 use v2::decl::Kind;
873 match &decl.kind {
874 Some(Kind::CommandDef(def)) => Some(def.timing.as_ref()),
875 Some(Kind::QueryDef(def)) => Some(def.timing.as_ref()),
876 _ => None,
877 }
878}
879
880fn contract(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
894 let Some((container, member)) = member_path(change) else {
895 return Verdict::Breaking;
896 };
897 let (Some(old_decl), Some(new_decl)) = (
898 find_old_interaction(old, new, container, member),
899 find_interaction(new, container, member),
900 ) else {
901 return Verdict::Breaking;
902 };
903 let (Some(old_clauses), Some(new_clauses)) = (contracts(old_decl), contracts(new_decl)) else {
904 return Verdict::Breaking;
905 };
906
907 let kind = |want: v2::ContractKind| {
908 move |clause: &&v2::Contract| v2::ContractKind::try_from(clause.kind) == Ok(want)
909 };
910 let sources = |clauses: &[v2::Contract], want: v2::ContractKind| -> Vec<String> {
911 let mut out: Vec<String> = clauses
912 .iter()
913 .filter(kind(want))
914 .map(|clause| clause.source.clone())
915 .collect();
916 out.sort();
917 out
918 };
919
920 let old_require = sources(old_clauses, v2::ContractKind::Require);
921 let new_require = sources(new_clauses, v2::ContractKind::Require);
922 let old_ensure = sources(old_clauses, v2::ContractKind::Ensure);
923 let new_ensure = sources(new_clauses, v2::ContractKind::Ensure);
924
925 if !covers(&old_require, &new_require) || !covers(&new_ensure, &old_ensure) {
926 return Verdict::Breaking;
927 }
928 Verdict::Compatible
929}
930
931fn covers(superset: &[String], subset: &[String]) -> bool {
933 let mut remaining: Vec<&String> = superset.iter().collect();
934 for clause in subset {
935 match remaining.iter().position(|held| *held == clause) {
936 Some(index) => {
937 remaining.swap_remove(index);
938 }
939 None => return false,
940 }
941 }
942 true
943}
944
945fn contracts(decl: &v2::Decl) -> Option<&[v2::Contract]> {
947 use v2::decl::Kind;
948 match &decl.kind {
949 Some(Kind::CommandDef(def)) => Some(&def.contracts),
950 Some(Kind::QueryDef(def)) => Some(&def.contracts),
951 _ => None,
952 }
953}
954
955fn member_path(change: &Change) -> Option<(&str, &str)> {
962 let mut segments = change.path.split('/').skip(1);
963 let container = segments.next()?;
964 let member = segments.next()?;
965 if segments.next().is_some() {
966 return None;
967 }
968 Some((container, member))
969}
970
971fn find_decl<'a>(package: &'a v2::Package, name: &str) -> Option<&'a v2::Decl> {
972 package.decls.iter().find(|decl| decl.name == name)
973}
974
975fn find_shape<'a>(package: &'a v2::Package, name: &str) -> Option<v2::InterfaceShape<'a>> {
980 package.shapes().find(|shape| shape.name == name)
981}
982
983fn find_old_shape<'a>(
990 old: &'a v2::Package,
991 new: &v2::Package,
992 name: &str,
993) -> Option<v2::InterfaceShape<'a>> {
994 let by_number = find_shape(new, name)
995 .filter(|shape| frozen(shape.interface))
996 .and_then(|shape| {
997 old.shapes().find(|candidate| {
998 frozen(candidate.interface) && candidate.interface.number == shape.interface.number
999 })
1000 });
1001 by_number.or_else(|| find_shape(old, name))
1002}
1003
1004fn find_interface<'a>(package: &'a v2::Package, name: &str) -> Option<&'a v2::Interface> {
1005 find_shape(package, name).map(|shape| shape.interface)
1006}
1007
1008fn find_old_interface<'a>(
1009 old: &'a v2::Package,
1010 new: &v2::Package,
1011 name: &str,
1012) -> Option<&'a v2::Interface> {
1013 find_old_shape(old, new, name).map(|shape| shape.interface)
1014}
1015
1016fn find_interaction<'a>(
1017 package: &'a v2::Package,
1018 container: &str,
1019 member: &str,
1020) -> Option<&'a v2::Decl> {
1021 find_interface(package, container)?
1022 .interactions
1023 .iter()
1024 .find(|decl| decl.name == member)
1025}
1026
1027fn find_old_interaction<'a>(
1030 old: &'a v2::Package,
1031 new: &v2::Package,
1032 container: &str,
1033 member: &str,
1034) -> Option<&'a v2::Decl> {
1035 find_old_interface(old, new, container)?
1036 .interactions
1037 .iter()
1038 .find(|decl| decl.name == member)
1039}
1040
1041fn slots(interface: &v2::Interface) -> Vec<(&str, u32)> {
1053 interface
1054 .interactions
1055 .iter()
1056 .filter_map(|decl| match &decl.kind {
1057 Some(v2::decl::Kind::ReservedSlot(reserved)) => {
1058 Some((reserved.name.as_deref().unwrap_or(""), decl.ordinal))
1059 }
1060 Some(_) => Some((decl.name.as_str(), decl.ordinal)),
1061 None => None,
1062 })
1063 .collect()
1064}
1065
1066fn slot_ordinal(interface: &v2::Interface, member: &str) -> Option<u32> {
1067 slots(interface)
1068 .into_iter()
1069 .find(|(name, _)| *name == member)
1070 .map(|(_, ordinal)| ordinal)
1071}
1072
1073pub(crate) fn struct_slots(def: &v2::StructDef) -> Vec<(String, i64)> {
1077 def.members
1078 .iter()
1079 .filter_map(|member| match &member.member {
1080 Some(v2::struct_member::Member::Field(field)) => {
1081 Some((field.name.clone(), i64::from(field.ordinal)))
1082 }
1083 _ => None,
1084 })
1085 .collect()
1086}
1087
1088fn struct_reserved(def: &v2::StructDef) -> Vec<i64> {
1089 def.members
1090 .iter()
1091 .filter_map(|member| match &member.member {
1092 Some(v2::struct_member::Member::Reserved(reserved)) => {
1093 Some(i64::from(reserved.ordinal))
1094 }
1095 _ => None,
1096 })
1097 .collect()
1098}
1099
1100pub(crate) fn union_slots(def: &v2::UnionDef) -> Vec<(String, i64)> {
1102 def.arms
1103 .iter()
1104 .map(|arm| (arm.name.clone(), i64::from(arm.ordinal)))
1105 .collect()
1106}
1107
1108fn union_reserved(def: &v2::UnionDef) -> Vec<i64> {
1109 def.reserved
1110 .iter()
1111 .map(|reserved| i64::from(reserved.ordinal))
1112 .collect()
1113}
1114
1115pub(crate) fn value_slots(values: &[v2::EnumValue]) -> Vec<(String, i64)> {
1118 values
1119 .iter()
1120 .map(|value| (value.name.clone(), value.value))
1121 .collect()
1122}
1123
1124fn reserved_values(reserved: &[v2::Reserved]) -> Vec<i64> {
1125 reserved.iter().filter_map(|entry| entry.value).collect()
1126}
1127
1128pub fn category_from_word(word: &str) -> Option<Category> {
1135 crate::CATEGORIES
1136 .into_iter()
1137 .find(|category| crate::category_word(*category) == word)
1138}
1139
1140#[deny(
1150 clippy::wildcard_enum_match_arm,
1151 clippy::match_wildcard_for_single_variants
1152)]
1153pub fn explain(category: Category) -> &'static str {
1154 match category {
1155 Category::DeclAdded => concat!(
1156 "A declaration, interface, or service present only in the new snapshot.\n",
1157 " compatible a new package-level decl, interface, or service; an enum\n",
1158 " value appended above every live and retired number; a\n",
1159 " union arm appended at the end of the body (typl 7.4,\n",
1160 " append-only); a struct field appended at the end of the body\n",
1161 " when it is optional, or when it is a scalar, enum, or enum set\n",
1162 " whose type allows the value 0\n",
1163 " breaking a member inserted below the highest slot ever used, a member\n",
1164 " taking a retired number, any addition that moves a surviving\n",
1165 " member's slot, or any arm of a result union (ADR-0008 d4); a\n",
1166 " non-optional struct field appended whose type does not allow\n",
1167 " 0 — a string, bytes, struct, union, tuple, array, or map, a\n",
1168 " scalar whose range or step excludes 0, an enum with no zero\n",
1169 " member, or a type the diff cannot resolve (ridl diff\n",
1170 " resolves ridl.std types). A reader of the new version\n",
1171 " refuses every payload of the old one, which does not carry\n",
1172 " the field (typl 7.4). Declare the new field optional instead\n",
1173 " (`field : T?`): an absent optional field reads as absent\n",
1174 " note an interface is matched by its interfaces.lock number (lock\n",
1175 " design 7): a frozen number the old side never held is a new\n",
1176 " interface, and so is every provisional one — a declaration\n",
1177 " with no lock entry carries no identity, and a rename keeps its\n",
1178 " number, so it is never a renamed interface (interface_renamed)"
1179 ),
1180 Category::DeclRemoved => concat!(
1181 "A declaration, interface, or service present only in the old snapshot.\n",
1182 " breaking a removed service, interface, decl, enum value, struct\n",
1183 " field, or union arm withdraws something a consumer compiled\n",
1184 " against\n",
1185 " note an interface is matched by its interfaces.lock number (lock\n",
1186 " design 7), so an interface here is one whose number is gone\n",
1187 " from the new side and not retired there — a lock line deleted\n",
1188 " by hand, or a number changed by hand, which is this row plus\n",
1189 " decl_added. A number the new side's lock retires is\n",
1190 " interface_retired instead, and `ridl baseline` refuses to\n",
1191 " publish the removal of a number it does not find retired\n",
1192 " (RIDL-412)\n",
1193 " caveat a composite member retired the sanctioned way — replaced by\n",
1194 " a `reserved` tombstone in its own slot (typl 7.4) — is also\n",
1195 " reported breaking today. The body comparison is keyed on\n",
1196 " member names and does not read the `reserved` list, so it\n",
1197 " cannot yet tell that retirement from a bare deletion. This\n",
1198 " errs on the safe side; carried as debt, see the note on\n",
1199 " `diff_composite`. The interaction-level tombstone IS\n",
1200 " recognised — see interaction_retired"
1201 ),
1202 Category::InterfaceRenamed => concat!(
1203 "An interface whose interfaces.lock number is the same on both sides and\n",
1204 "whose name changed.\n",
1205 " compatible always — the number is the interface's identity and its\n",
1206 " routing key (lock design 7, rsdl decision D-7), so nothing\n",
1207 " moves on the wire. Visible in source: a rename changes the\n",
1208 " generated identity-table names in both wire backends, which\n",
1209 " is why the text report lists it under the heading\n",
1210 " \"compatible on the wire, visible in source\". The path\n",
1211 " carries the new name and the detail old -> new; changes\n",
1212 " inside the interface are reported under the new name and\n",
1213 " classified as any other interface's\n",
1214 " note a provisional number is no identity: a declaration with no\n",
1215 " lock entry is never matched, so a rename the lock does not\n",
1216 " record is decl_removed plus decl_added, breaking, until\n",
1217 " `ridl lock <pkg> --rename Old=New` records it"
1218 ),
1219 Category::InterfaceRetired => concat!(
1220 "An interface whose number the old snapshot held is gone from the new\n",
1221 "snapshot, whose interfaces.lock retires it.\n",
1222 " compatible always — the sanctioned removal of an interface: the entry\n",
1223 " keeps its number forever with the word `retired`, so the\n",
1224 " number is never allocated again (lock design 4 and 7, rsdl\n",
1225 " decision D-7). A number gone with no retired entry is\n",
1226 " decl_removed, breaking, and `ridl baseline` refuses to publish\n",
1227 " it (RIDL-412)"
1228 ),
1229 Category::InterfaceFrozen => concat!(
1230 "An interface matched by name whose number is the same on both sides and\n",
1231 "whose provisional flag went from true to false: `ridl lock` froze the\n",
1232 "number in place.\n",
1233 " compatible always — the number is the routing key and it did not\n",
1234 " change, so nothing moves on the wire. The catalog hash\n",
1235 " changes, because it covers each interface's number and its\n",
1236 " provisional flag, and a generated face refuses a port whose\n",
1237 " catalog hash is another one (ADR-0023 decision 8): a peer\n",
1238 " built before the lock and a peer built after it refuse each\n",
1239 " other. This is why the text report lists it under the\n",
1240 " heading \"compatible on the wire, may change the catalog\n",
1241 " hash\".\n",
1242 " The detail carries the number before and after, marked\n",
1243 " (provisional) on the old side\n",
1244 " note a baseline never holds a provisional number (`ridl baseline`\n",
1245 " refuses one, RIDL-411), so this change is seen between two\n",
1246 " builds, or in a reversed comparison whose old side is a\n",
1247 " build. A number 0 on either side, from a snapshot written\n",
1248 " before the lock existed, is not compared"
1249 ),
1250 Category::InterfaceNumberChanged => concat!(
1251 "An interface matched across the two sides whose number differs.\n",
1252 " breaking always — the number is the interface's routing key, so a\n",
1253 " peer built against the old number cannot reach the\n",
1254 " interface at the new one. Seen between two provisional\n",
1255 " builds when a sibling interface added before this one in\n",
1256 " byte order moves its provisional number, and when `ridl\n",
1257 " lock` freezes a provisional number as another number. The\n",
1258 " detail carries the old and the new number, each marked\n",
1259 " (provisional) when it is\n",
1260 " note two frozen numbers are matched by number, so they never\n",
1261 " differ here: a frozen number changed by hand is\n",
1262 " decl_removed plus decl_added. A number 0 on either side,\n",
1263 " from a snapshot written before the lock existed, is not\n",
1264 " compared.\n",
1265 " Run `ridl lock` to freeze the numbers, so that a later\n",
1266 " sibling cannot move them"
1267 ),
1268 Category::MemberReordered => concat!(
1269 "A surviving composite member whose slot in the body changed.\n",
1270 " breaking always — a struct field or union arm takes its wire\n",
1271 " identity from its ordinal, its 1-based place in the body\n",
1272 " counting tombstones (typl 7.4), so a member whose ordinal\n",
1273 " changed has a new wire identity; the detail carries the old\n",
1274 " and new ordinal. An enum value or enum-set bit takes its\n",
1275 " identity from the explicit number it declares (typl 8, 9),\n",
1276 " so one whose number changed has a new wire identity; the\n",
1277 " detail carries the old and new value or bit. An enum or\n",
1278 " enum-set body reordered with no number changed is\n",
1279 " enum_reordered, compatible, instead\n",
1280 " note reported only when both bodies hold the same member names:\n",
1281 " a reorder in the same edit as an addition or a removal is\n",
1282 " reported through decl_added or decl_removed alone. A reorder\n",
1283 " in the same edit as an in-place change to the body is\n",
1284 " reported with constraint_changed on the container as well.\n",
1285 " An enum or enum-set body whose only differences are changed\n",
1286 " numbers, in any order, reports member_reordered alone"
1287 ),
1288 Category::EnumReordered => concat!(
1289 "An enum or enum-set body whose difference is only order: its values or\n",
1290 "bits reordered with no number changed, the enum's reserved list\n",
1291 "reordered with no entry added, removed or changed, or both.\n",
1292 " compatible always — a value or bit takes its identity from the\n",
1293 " explicit number it declares (typl 8, 9), so nothing moves\n",
1294 " on the wire. The catalog hash covers only the declarations\n",
1295 " an interface reaches. When an interface reaches the enum or\n",
1296 " enum set, the order of its values or bits, and of an enum's\n",
1297 " reserved list, is part of the hash, so the hash changes\n",
1298 " then, and a generated face refuses a port whose catalog hash\n",
1299 " is another one (ADR-0023 decision 8). This is why the text\n",
1300 " report lists it under the heading \"compatible on the wire,\n",
1301 " may change the catalog hash\". The path is the container's;\n",
1302 " there is no detail\n",
1303 " note a value or bit whose number changed is member_reordered,\n",
1304 " breaking, not enum_reordered"
1305 ),
1306 Category::InteractionAppended => concat!(
1307 "An interaction added after every slot that existed before.\n",
1308 " compatible the slot it takes was never occupied\n",
1309 " breaking the slot was freed by an untombstoned removal and is now\n",
1310 " reused by a new name — a reused wire identity (ADR-0008 d14)"
1311 ),
1312 Category::InteractionInserted => concat!(
1313 "An interaction added before the end of the interface body.\n",
1314 " breaking always — every later ordinal shifts, and the ordinal is the\n",
1315 " transport identity (ridl 11)"
1316 ),
1317 Category::InteractionReordered => concat!(
1318 "A surviving interaction whose relative order in the body changed.\n",
1319 " breaking always — a reorder shifts wire identities (ridl 11)"
1320 ),
1321 Category::InteractionRemoved => concat!(
1322 "An interaction removed without a `reserved` tombstone holding its slot.\n",
1323 " breaking always — the freed ordinal is reusable, so the wire identity\n",
1324 " is no longer permanent (ridl 11)"
1325 ),
1326 Category::InteractionRetired => concat!(
1327 "An interaction retired to a `reserved` tombstone in its own ordinal slot.\n",
1328 " compatible always — the sanctioned retirement: the slot stays occupied\n",
1329 " and every later ordinal holds (ridl 11)"
1330 ),
1331 Category::KindChanged => concat!(
1332 "An interaction whose kind changed (signal, event, command, query, fixed).\n",
1333 " breaking any direction — the kind selects the transport shape"
1334 ),
1335 Category::PayloadChanged => concat!(
1336 "A signal, event, or fixed payload type changed.\n",
1337 " breaking any direction, a stream added or removed included"
1338 ),
1339 Category::ReturnChanged => concat!(
1340 "A query return shape changed.\n",
1341 " breaking any direction — an ok-arm change, an error arm added, removed,\n",
1342 " or retyped, a stream added or removed, or any other change to\n",
1343 " the synthesized inline `T | E` transport identity (ADR-0008 d4:\n",
1344 " interface + interaction ordinal + ordered arm types)"
1345 ),
1346 Category::ParamsChanged => concat!(
1347 "A command or query parameter list changed.\n",
1348 " breaking any direction — a parameter added, removed, renamed, retyped,\n",
1349 " or a stream added or removed on one"
1350 ),
1351 Category::TimingChanged => concat!(
1352 "A signal or event resolved timing changed (ADR-0008 d12).\n",
1353 " compatible min raised (a higher rate floor) or max lowered (a tighter\n",
1354 " staleness bound) with the mode unchanged; default_applied\n",
1355 " flipped over identical resolved bounds — a default made\n",
1356 " explicit\n",
1357 " breaking min lowered, max raised, a bound added where none was, a bound\n",
1358 " removed, or the strict-periodic/range mode flipped\n",
1359 " note editing `[defaults].timing` needs no special rule: diff\n",
1360 " compares resolved bounds, so it surfaces here on every\n",
1361 " defaulted interaction (ridl 9.1)"
1362 ),
1363 Category::RpcBoundChanged => concat!(
1364 "A command or query declared RPC bound changed (ADR-0015 d8).\n",
1365 " compatible min lowered (the caller may call more often) or max lowered\n",
1366 " (a stronger provider promise), with the mode unchanged;\n",
1367 " default_applied flipped over identical resolved bounds — a\n",
1368 " default made explicit\n",
1369 " breaking min raised — on an RPC, min is the call throttle and\n",
1370 " constrains the caller, so raising it withdraws a call rate\n",
1371 " the caller was entitled to use; max raised (a weaker provider\n",
1372 " promise); a bound added or removed, the whole annotation\n",
1373 " included\n",
1374 " note the min direction is the inverse of timing_changed's, which\n",
1375 " is why this is a category of its own rather than a branch:\n",
1376 " a missed branch would inherit the signal rule and call a\n",
1377 " raised RPC min compatible (ADR-0012 d9, fail closed).\n",
1378 " an RPC bound may come from a default, and diff compares\n",
1379 " resolved bounds, so editing a default surfaces here on\n",
1380 " every defaulted command or query (ridl 9.1)"
1381 ),
1382 Category::LimitChanged => concat!(
1383 "The payload limit of the interaction changed; every change of a limit is\n",
1384 "breaking, ADR-0030 decision 13.\n",
1385 " breaking a limit raised, lowered, added where none was, or removed"
1386 ),
1387 Category::ContractChanged => concat!(
1388 "A command or query require/ensure clause set changed (ridl 13).\n",
1389 " compatible a require removed, or an ensure added\n",
1390 " breaking a require added or its text changed, or an ensure removed or\n",
1391 " its text changed. Clause text is compared verbatim: the\n",
1392 " classifier does not prove that one clause implies another\n",
1393 " (ADR-0008 d14)"
1394 ),
1395 Category::InteractionClassChanged => concat!(
1396 "A name-paired interaction's family or operation shape changed\n",
1397 "(ADR-0012 d6).\n",
1398 " breaking any change, in either direction: the family selects the\n",
1399 " obligations a consumer compiled against, and the shape\n",
1400 " the gesture an intent command is. No source writes a\n",
1401 " family other than dispatch yet; the walk compares the\n",
1402 " fields from the start so the diff fails closed (ADR-0012 d9)"
1403 ),
1404 Category::WidthChanged => concat!(
1405 "A derived wire width or scalar backing changed.\n",
1406 " breaking any IntWidth or FloatWidth change, uint64 versus int64\n",
1407 " included — the resolved width is part of the contract\n",
1408 " (typl 4.2, 5.6)"
1409 ),
1410 Category::ConstraintChanged => concat!(
1411 "A scalar constraint changed, or a composite body changed in place.\n",
1412 " compatible widened — min lowered without shifting a stepped grid, max\n",
1413 " raised, a length bound loosened,\n",
1414 " a step removed, a match pattern removed (by literal or by\n",
1415 " named constant), or the whole constraint dropped so the\n",
1416 " value is unbounded again\n",
1417 " breaking narrowed — min raised, max lowered, a length bound\n",
1418 " tightened, a stepped grid shifted, a step added or changed\n",
1419 " (divisibility between different steps is never\n",
1420 " proved), a match pattern added or rewritten (by literal or\n",
1421 " by named constant), or a constraint appearing where there\n",
1422 " was none, which bounds a previously unbounded value. A\n",
1423 " composite body changed in place is breaking: the walk\n",
1424 " does not say which member changed inside it\n",
1425 " note each facet is judged on its own and any one narrowing\n",
1426 " decides the change, so a mixed edit is breaking on the half\n",
1427 " that narrows. A widening that flips the resolved wire width\n",
1428 " is separately reported as width_changed, which is always\n",
1429 " breaking (typl 5.6)"
1430 ),
1431 Category::InitChanged => concat!(
1432 "A declared or resolved init value changed.\n",
1433 " breaking any direction — the init is the value a consumer reads before\n",
1434 " the first publish, so it is part of the contract (ridl 9.1)"
1435 ),
1436 Category::ReservedNameRedeclared => concat!(
1437 "A name retired by a `reserved` tombstone is live again — an interaction\n",
1438 "inside an interface body.\n",
1439 " breaking always — a retired identity is never reused (ridl 11,\n",
1440 " RIDL-401)"
1441 ),
1442 Category::ServiceChanged => concat!(
1443 "A service switched between the named list and an inline shape.\n",
1444 " breaking always — extraction rewrites the transport identity of every\n",
1445 " fallible query in the shape: an inline shape derives it from\n",
1446 " the service's dotted name, a named interface from its own name\n",
1447 " (ADR-0008 d4, ADR-0015 d15). A changed list is not this\n",
1448 " category: it is read as a set by the service_interface_*\n",
1449 " rows (ADR-0015 d19, as amended 2026-09-15)"
1450 ),
1451 Category::ServiceInterfaceAdded => concat!(
1452 "An interface joined a service's set of interfaces.\n",
1453 " compatible always — nothing that existed moved: an interface's number\n",
1454 " comes from its unit's interfaces.lock, not from its place\n",
1455 " in the list, and the routing key does not contain the service\n",
1456 " (ADR-0015 d19, as amended 2026-09-15)"
1457 ),
1458 Category::ServiceInterfaceRemoved => concat!(
1459 "An interface left a service's set of interfaces.\n",
1460 " compatible always — on the wire: the routing key does not contain the\n",
1461 " service, so no identity moves, and a split into two services\n",
1462 " is a removal plus an addition. Visible in source: the\n",
1463 " service.member addresses of that interface stop resolving\n",
1464 " under this service, which is why the text report lists it\n",
1465 " under the heading \"compatible on the wire, visible in\n",
1466 " source\". A consumer that loses its only provider is a\n",
1467 " wiring error for rsdl, not a package diff (ADR-0015 d19, as\n",
1468 " amended 2026-09-15)"
1469 ),
1470 Category::DocOnly => concat!(
1471 "Only doc comment, labels, or deprecation metadata changed.\n",
1472 " compatible always — none of it reaches a consumer's build.\n",
1473 " Visibility is NOT in this category: see\n",
1474 " visibility_changed"
1475 ),
1476 Category::VisibilityChanged => concat!(
1477 "The visibility a declaration is published at changed.\n",
1478 " compatible internal -> public: the declaration is offered to more\n",
1479 " consumers than before\n",
1480 " breaking public -> internal: `internal` maps to the target's\n",
1481 " package-private mechanism — Rust `pub(crate)`, a\n",
1482 " non-exported TypeScript member (ADR-0002 8) — so the\n",
1483 " declaration disappears from every out-of-package\n",
1484 " consumer's build. The wire layout does not move, but the\n",
1485 " consumer-visible guarantee narrows (ADR-0008 d14). Any\n",
1486 " direction involving an unset visibility is breaking"
1487 ),
1488 }
1489}