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::ServiceInterfaceAdded | Category::ServiceInterfaceRemoved => Verdict::Compatible,
114
115 Category::VisibilityChanged => visibility(change, old, new),
116 Category::InteractionAppended => appended(change, old, new),
117 Category::DeclAdded => added(change, old, new, scope),
118 Category::ConstraintChanged => constraint(change, old, new),
119 Category::TimingChanged => timing(change, old, new),
120 Category::RpcBoundChanged => rpc_bound(change, old, new),
121 Category::ContractChanged => contract(change, old, new),
122 }
123}
124
125fn visibility(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
143 let (Some(old_visibility), Some(new_visibility)) = (
144 find_visibility(old, &change.path, |name| find_old_shape(old, new, name)),
145 find_visibility(new, &change.path, |name| find_shape(new, name)),
146 ) else {
147 return Verdict::Breaking;
148 };
149
150 match (
151 v2::Visibility::try_from(old_visibility),
152 v2::Visibility::try_from(new_visibility),
153 ) {
154 (Ok(v2::Visibility::Internal), Ok(v2::Visibility::Public)) => Verdict::Compatible,
155 _ => Verdict::Breaking,
156 }
157}
158
159fn find_visibility<'a>(
166 package: &'a v2::Package,
167 path: &str,
168 shape_of: impl Fn(&str) -> Option<v2::InterfaceShape<'a>>,
169) -> Option<i32> {
170 let mut segments = path.split('/').skip(1);
171 let name = segments.next()?;
172
173 if let Some(member) = segments.next() {
174 return shape_of(name)?
175 .interface
176 .interactions
177 .iter()
178 .find(|decl| decl.name == member)
179 .map(|decl| decl.visibility);
180 }
181 if let Some(decl) = find_decl(package, name) {
182 return Some(decl.visibility);
183 }
184 if let Some(shape) = shape_of(name) {
189 return Some(shape.visibility());
190 }
191 package
194 .services
195 .iter()
196 .find(|service| service.name == name)
197 .map(|service| service.visibility)
198}
199
200fn appended(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
215 let Some((container, member)) = member_path(change) else {
216 return Verdict::Breaking;
217 };
218 let (Some(old_iface), Some(new_iface)) = (
219 find_old_interface(old, new, container),
220 find_interface(new, container),
221 ) else {
222 return Verdict::Breaking;
223 };
224 let Some(ordinal) = slot_ordinal(new_iface, member) else {
225 return Verdict::Breaking;
226 };
227
228 for (name, old_ordinal) in slots(old_iface) {
229 if old_ordinal == ordinal && name != member {
230 return Verdict::Breaking;
231 }
232 }
233 Verdict::Compatible
234}
235
236fn added(change: &Change, old: &v2::Package, new: &v2::Package, scope: &[&v2::Package]) -> Verdict {
252 let Some((container, member)) = member_path(change) else {
253 return Verdict::Compatible;
256 };
257 let (Some(old_decl), Some(new_decl)) = (find_decl(old, container), find_decl(new, container))
258 else {
259 return Verdict::Breaking;
260 };
261
262 use v2::decl::Kind;
267 let appended = match (&old_decl.kind, &new_decl.kind) {
268 (Some(Kind::StructDef(old_def)), Some(Kind::StructDef(new_def))) => {
269 appended_slot(
270 &struct_slots(old_def),
271 &struct_reserved(old_def),
272 &struct_slots(new_def),
273 ) && absence_reads_as_legal(new_def, member, new, scope)
274 }
275 (Some(Kind::UnionDef(old_def)), Some(Kind::UnionDef(new_def))) => {
276 !old_def.is_result
279 && !new_def.is_result
280 && appended_slot(
281 &union_slots(old_def),
282 &union_reserved(old_def),
283 &union_slots(new_def),
284 )
285 }
286 (Some(Kind::EnumDef(old_def)), Some(Kind::EnumDef(new_def))) => appended_slot(
287 &value_slots(&old_def.values),
288 &reserved_values(&old_def.reserved),
289 &value_slots(&new_def.values),
290 ),
291 (Some(Kind::EnumSetDef(old_def)), Some(Kind::EnumSetDef(new_def))) => appended_slot(
292 &value_slots(&old_def.bits),
293 &[],
294 &value_slots(&new_def.bits),
295 ),
296 _ => false,
298 };
299
300 if appended {
301 Verdict::Compatible
302 } else {
303 Verdict::Breaking
304 }
305}
306
307pub fn absence_refused(
316 new_set: &[v2::Package],
317 context: &[v2::Package],
318 package: &str,
319 container: &str,
320 member: &str,
321) -> bool {
322 let scope: Vec<&v2::Package> = new_set.iter().chain(context).collect();
323 let Some(home) = new_set.iter().find(|candidate| candidate.name == package) else {
324 return false;
325 };
326 match find_decl(home, container).and_then(|decl| decl.kind.as_ref()) {
327 Some(v2::decl::Kind::StructDef(def)) => !absence_reads_as_legal(def, member, home, &scope),
328 _ => false,
329 }
330}
331
332fn absence_reads_as_legal(
352 def: &v2::StructDef,
353 member: &str,
354 home: &v2::Package,
355 scope: &[&v2::Package],
356) -> bool {
357 let Some(r#type) = def.members.iter().find_map(|slot| match &slot.member {
358 Some(v2::struct_member::Member::Field(field)) if field.name == member => {
359 field.r#type.as_ref()
360 }
361 _ => None,
362 }) else {
363 return false;
364 };
365 if r#type.optional {
366 return true;
367 }
368 use v2::field_type::Kind;
369 match &r#type.kind {
370 Some(Kind::Primitive(primitive)) => matches!(
371 v2::PrimitiveType::try_from(*primitive),
372 Ok(v2::PrimitiveType::Boolean | v2::PrimitiveType::Integer | v2::PrimitiveType::Float)
373 ),
374 Some(Kind::InlineScalar(def)) => scalar_holds_zero(def),
375 Some(Kind::Named(reference)) => {
376 use v2::decl::Kind as Decl;
377 match resolve(home, scope, reference).and_then(|decl| decl.kind.as_ref()) {
378 Some(Decl::TypeDef(def)) => scalar_holds_zero(def),
379 Some(Decl::EnumDef(def)) => ridl_ir::zero::enum_zero_member(&def.values).is_some(),
380 Some(Decl::EnumSetDef(_)) => true,
382 _ => false,
383 }
384 }
385 _ => false,
386 }
387}
388
389fn scalar_holds_zero(def: &v2::TypeDef) -> bool {
393 let numeric = match def
394 .backing
395 .as_ref()
396 .and_then(|backing| backing.kind.as_ref())
397 {
398 Some(v2::backing::Kind::Unit(_)) => true,
399 Some(v2::backing::Kind::Primitive(primitive)) => matches!(
400 v2::PrimitiveType::try_from(*primitive),
401 Ok(v2::PrimitiveType::Boolean | v2::PrimitiveType::Integer | v2::PrimitiveType::Float)
402 ),
403 None => false,
404 };
405 numeric
406 && def.constraint.as_ref().is_none_or(|constraint| {
407 ridl_ir::zero::range_holds_zero(
408 constraint.min.as_deref(),
409 constraint.max.as_deref(),
410 constraint.step.as_deref(),
411 )
412 })
413}
414
415fn resolve<'a>(
419 home: &'a v2::Package,
420 scope: &[&'a v2::Package],
421 reference: &str,
422) -> Option<&'a v2::Decl> {
423 match reference.rsplit_once('.') {
424 Some((package, name)) => std::iter::once(home)
425 .chain(scope.iter().copied())
426 .find(|candidate| candidate.name == package)
427 .and_then(|candidate| find_decl(candidate, name)),
428 None => find_decl(home, reference),
429 }
430}
431
432fn appended_slot(old: &[(String, i64)], old_retired: &[i64], new: &[(String, i64)]) -> bool {
443 for (name, old_slot) in old {
444 match new.iter().find(|(new_name, _)| new_name == name) {
445 Some((_, new_slot)) if new_slot == old_slot => {}
446 _ => return false,
448 }
449 }
450
451 let high_water = old
452 .iter()
453 .map(|(_, slot)| *slot)
454 .chain(old_retired.iter().copied())
455 .max();
456 let old_names: Vec<&String> = old.iter().map(|(name, _)| name).collect();
457 for (name, slot) in new {
458 if old_names.contains(&name) {
459 continue;
460 }
461 match high_water {
462 Some(mark) if *slot <= mark => return false,
463 _ => {}
464 }
465 }
466 true
467}
468
469fn constraint(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
481 let mut segments = change.path.split('/').skip(1);
482 let (Some(name), None) = (segments.next(), segments.next()) else {
483 return Verdict::Breaking;
484 };
485 let (Some(old_decl), Some(new_decl)) = (find_decl(old, name), find_decl(new, name)) else {
486 return Verdict::Breaking;
487 };
488
489 use v2::decl::Kind;
490 let (Some(Kind::TypeDef(old_def)), Some(Kind::TypeDef(new_def))) =
491 (&old_decl.kind, &new_decl.kind)
492 else {
493 return Verdict::Breaking;
494 };
495
496 if narrows(old_def.constraint.as_ref(), new_def.constraint.as_ref()) {
497 Verdict::Breaking
498 } else {
499 Verdict::Compatible
500 }
501}
502
503fn narrows(old: Option<&v2::Constraint>, new: Option<&v2::Constraint>) -> bool {
509 let (Some(old), Some(new)) = (old, new) else {
510 return old.is_none() && new.is_some();
513 };
514
515 if raised(old.min.as_deref(), new.min.as_deref())
517 || lowered(old.max.as_deref(), new.max.as_deref())
518 {
519 return true;
520 }
521 if raised_u64(old.len_min, new.len_min) || lowered_u64(old.len_max, new.len_max) {
523 return true;
524 }
525 if new.step.is_some() && old.step != new.step {
528 return true;
529 }
530 if (new.pattern.is_some() && old.pattern != new.pattern)
533 || (new.pattern_const.is_some() && old.pattern_const != new.pattern_const)
534 {
535 return true;
536 }
537 false
538}
539
540fn raised(old: Option<&str>, new: Option<&str>) -> bool {
542 match (old, new) {
543 (None, Some(_)) => true,
544 (Some(old), Some(new)) => cmp_decimal(new, old).is_none_or(std::cmp::Ordering::is_gt),
545 _ => false,
546 }
547}
548
549fn lowered(old: Option<&str>, new: Option<&str>) -> bool {
551 match (old, new) {
552 (None, Some(_)) => true,
553 (Some(old), Some(new)) => cmp_decimal(new, old).is_none_or(std::cmp::Ordering::is_lt),
554 _ => false,
555 }
556}
557
558fn raised_u64(old: Option<u64>, new: Option<u64>) -> bool {
559 match (old, new) {
560 (None, Some(_)) => true,
561 (Some(old), Some(new)) => new > old,
562 _ => false,
563 }
564}
565
566fn lowered_u64(old: Option<u64>, new: Option<u64>) -> bool {
567 match (old, new) {
568 (None, Some(_)) => true,
569 (Some(old), Some(new)) => new < old,
570 _ => false,
571 }
572}
573
574fn cmp_decimal(left: &str, right: &str) -> Option<std::cmp::Ordering> {
584 use std::cmp::Ordering;
585
586 let (left_negative, left_digits) = split_sign(left)?;
587 let (right_negative, right_digits) = split_sign(right)?;
588 if left_negative != right_negative {
589 return Some(if left_negative {
592 Ordering::Less
593 } else {
594 Ordering::Greater
595 });
596 }
597
598 let magnitude = cmp_magnitude(&left_digits, &right_digits)?;
599 Some(if left_negative {
600 magnitude.reverse()
601 } else {
602 magnitude
603 })
604}
605
606fn split_sign(text: &str) -> Option<(bool, String)> {
609 let (negative, rest) = match text.strip_prefix('-') {
610 Some(rest) => (true, rest),
611 None => (false, text.strip_prefix('+').unwrap_or(text)),
612 };
613 if rest.is_empty() || !rest.chars().all(|c| c.is_ascii_digit() || c == '.') {
614 return None;
615 }
616 if rest.matches('.').count() > 1 {
617 return None;
618 }
619 Some((negative, rest.to_string()))
620}
621
622fn cmp_magnitude(left: &str, right: &str) -> Option<std::cmp::Ordering> {
625 use std::cmp::Ordering;
626
627 let (left_int, left_frac) = left.split_once('.').unwrap_or((left, ""));
628 let (right_int, right_frac) = right.split_once('.').unwrap_or((right, ""));
629
630 let left_int = left_int.trim_start_matches('0');
631 let right_int = right_int.trim_start_matches('0');
632 let by_length = left_int.len().cmp(&right_int.len());
633 if by_length != Ordering::Equal {
634 return Some(by_length);
635 }
636 let by_int = left_int.cmp(right_int);
637 if by_int != Ordering::Equal {
638 return Some(by_int);
639 }
640
641 let width = left_frac.len().max(right_frac.len());
643 let pad = |frac: &str| format!("{frac:0<width$}");
644 Some(pad(left_frac).cmp(&pad(right_frac)))
645}
646
647fn timing(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
659 let Some((container, member)) = member_path(change) else {
660 return Verdict::Breaking;
661 };
662 let (Some(old_decl), Some(new_decl)) = (
663 find_old_interaction(old, new, container, member),
664 find_interaction(new, container, member),
665 ) else {
666 return Verdict::Breaking;
667 };
668 let (Some(old_timing), Some(new_timing)) =
669 (interaction_timing(old_decl), interaction_timing(new_decl))
670 else {
671 return Verdict::Breaking;
681 };
682
683 let (Some(old_timing), Some(new_timing)) = (old_timing, new_timing) else {
684 return Verdict::Breaking;
687 };
688
689 if old_timing.mode != new_timing.mode {
690 return Verdict::Breaking;
691 }
692 if lowered(old_timing.min_us.as_deref(), new_timing.min_us.as_deref())
694 || dropped(old_timing.min_us.as_deref(), new_timing.min_us.as_deref())
695 || raised(old_timing.max_us.as_deref(), new_timing.max_us.as_deref())
696 || dropped(old_timing.max_us.as_deref(), new_timing.max_us.as_deref())
697 {
698 return Verdict::Breaking;
699 }
700 Verdict::Compatible
703}
704
705fn rpc_bound(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
724 let Some((container, member)) = member_path(change) else {
725 return Verdict::Breaking;
726 };
727 let (Some(old_decl), Some(new_decl)) = (
728 find_old_interaction(old, new, container, member),
729 find_interaction(new, container, member),
730 ) else {
731 return Verdict::Breaking;
732 };
733 let (Some(old_timing), Some(new_timing)) = (rpc_timing(old_decl), rpc_timing(new_decl)) else {
734 return Verdict::Breaking;
741 };
742
743 let (Some(old_timing), Some(new_timing)) = (old_timing, new_timing) else {
744 return Verdict::Breaking;
747 };
748
749 if old_timing.mode != new_timing.mode {
752 return Verdict::Breaking;
753 }
754 if old_timing.default_applied != new_timing.default_applied {
761 return Verdict::Breaking;
762 }
763 if raised(old_timing.min_us.as_deref(), new_timing.min_us.as_deref())
768 || dropped(old_timing.min_us.as_deref(), new_timing.min_us.as_deref())
769 || raised(old_timing.max_us.as_deref(), new_timing.max_us.as_deref())
770 || dropped(old_timing.max_us.as_deref(), new_timing.max_us.as_deref())
771 {
772 return Verdict::Breaking;
773 }
774 Verdict::Compatible
777}
778
779fn dropped(old: Option<&str>, new: Option<&str>) -> bool {
781 old.is_some() && new.is_none()
782}
783
784fn interaction_timing(decl: &v2::Decl) -> Option<Option<&v2::Timing>> {
787 use v2::decl::Kind;
788 match &decl.kind {
789 Some(Kind::SignalDef(def)) => Some(def.timing.as_ref()),
790 Some(Kind::EventDef(def)) => Some(def.timing.as_ref()),
791 _ => None,
792 }
793}
794
795fn rpc_timing(decl: &v2::Decl) -> Option<Option<&v2::Timing>> {
799 use v2::decl::Kind;
800 match &decl.kind {
801 Some(Kind::CommandDef(def)) => Some(def.timing.as_ref()),
802 Some(Kind::QueryDef(def)) => Some(def.timing.as_ref()),
803 _ => None,
804 }
805}
806
807fn contract(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
821 let Some((container, member)) = member_path(change) else {
822 return Verdict::Breaking;
823 };
824 let (Some(old_decl), Some(new_decl)) = (
825 find_old_interaction(old, new, container, member),
826 find_interaction(new, container, member),
827 ) else {
828 return Verdict::Breaking;
829 };
830 let (Some(old_clauses), Some(new_clauses)) = (contracts(old_decl), contracts(new_decl)) else {
831 return Verdict::Breaking;
832 };
833
834 let kind = |want: v2::ContractKind| {
835 move |clause: &&v2::Contract| v2::ContractKind::try_from(clause.kind) == Ok(want)
836 };
837 let sources = |clauses: &[v2::Contract], want: v2::ContractKind| -> Vec<String> {
838 let mut out: Vec<String> = clauses
839 .iter()
840 .filter(kind(want))
841 .map(|clause| clause.source.clone())
842 .collect();
843 out.sort();
844 out
845 };
846
847 let old_require = sources(old_clauses, v2::ContractKind::Require);
848 let new_require = sources(new_clauses, v2::ContractKind::Require);
849 let old_ensure = sources(old_clauses, v2::ContractKind::Ensure);
850 let new_ensure = sources(new_clauses, v2::ContractKind::Ensure);
851
852 if !covers(&old_require, &new_require) || !covers(&new_ensure, &old_ensure) {
853 return Verdict::Breaking;
854 }
855 Verdict::Compatible
856}
857
858fn covers(superset: &[String], subset: &[String]) -> bool {
860 let mut remaining: Vec<&String> = superset.iter().collect();
861 for clause in subset {
862 match remaining.iter().position(|held| *held == clause) {
863 Some(index) => {
864 remaining.swap_remove(index);
865 }
866 None => return false,
867 }
868 }
869 true
870}
871
872fn contracts(decl: &v2::Decl) -> Option<&[v2::Contract]> {
874 use v2::decl::Kind;
875 match &decl.kind {
876 Some(Kind::CommandDef(def)) => Some(&def.contracts),
877 Some(Kind::QueryDef(def)) => Some(&def.contracts),
878 _ => None,
879 }
880}
881
882fn member_path(change: &Change) -> Option<(&str, &str)> {
889 let mut segments = change.path.split('/').skip(1);
890 let container = segments.next()?;
891 let member = segments.next()?;
892 if segments.next().is_some() {
893 return None;
894 }
895 Some((container, member))
896}
897
898fn find_decl<'a>(package: &'a v2::Package, name: &str) -> Option<&'a v2::Decl> {
899 package.decls.iter().find(|decl| decl.name == name)
900}
901
902fn find_shape<'a>(package: &'a v2::Package, name: &str) -> Option<v2::InterfaceShape<'a>> {
907 package.shapes().find(|shape| shape.name == name)
908}
909
910fn find_old_shape<'a>(
917 old: &'a v2::Package,
918 new: &v2::Package,
919 name: &str,
920) -> Option<v2::InterfaceShape<'a>> {
921 let by_number = find_shape(new, name)
922 .filter(|shape| frozen(shape.interface))
923 .and_then(|shape| {
924 old.shapes().find(|candidate| {
925 frozen(candidate.interface) && candidate.interface.number == shape.interface.number
926 })
927 });
928 by_number.or_else(|| find_shape(old, name))
929}
930
931fn find_interface<'a>(package: &'a v2::Package, name: &str) -> Option<&'a v2::Interface> {
932 find_shape(package, name).map(|shape| shape.interface)
933}
934
935fn find_old_interface<'a>(
936 old: &'a v2::Package,
937 new: &v2::Package,
938 name: &str,
939) -> Option<&'a v2::Interface> {
940 find_old_shape(old, new, name).map(|shape| shape.interface)
941}
942
943fn find_interaction<'a>(
944 package: &'a v2::Package,
945 container: &str,
946 member: &str,
947) -> Option<&'a v2::Decl> {
948 find_interface(package, container)?
949 .interactions
950 .iter()
951 .find(|decl| decl.name == member)
952}
953
954fn find_old_interaction<'a>(
957 old: &'a v2::Package,
958 new: &v2::Package,
959 container: &str,
960 member: &str,
961) -> Option<&'a v2::Decl> {
962 find_old_interface(old, new, container)?
963 .interactions
964 .iter()
965 .find(|decl| decl.name == member)
966}
967
968fn slots(interface: &v2::Interface) -> Vec<(&str, u32)> {
980 interface
981 .interactions
982 .iter()
983 .filter_map(|decl| match &decl.kind {
984 Some(v2::decl::Kind::ReservedSlot(reserved)) => {
985 Some((reserved.name.as_deref().unwrap_or(""), decl.ordinal))
986 }
987 Some(_) => Some((decl.name.as_str(), decl.ordinal)),
988 None => None,
989 })
990 .collect()
991}
992
993fn slot_ordinal(interface: &v2::Interface, member: &str) -> Option<u32> {
994 slots(interface)
995 .into_iter()
996 .find(|(name, _)| *name == member)
997 .map(|(_, ordinal)| ordinal)
998}
999
1000pub(crate) fn struct_slots(def: &v2::StructDef) -> Vec<(String, i64)> {
1004 def.members
1005 .iter()
1006 .filter_map(|member| match &member.member {
1007 Some(v2::struct_member::Member::Field(field)) => {
1008 Some((field.name.clone(), i64::from(field.ordinal)))
1009 }
1010 _ => None,
1011 })
1012 .collect()
1013}
1014
1015fn struct_reserved(def: &v2::StructDef) -> Vec<i64> {
1016 def.members
1017 .iter()
1018 .filter_map(|member| match &member.member {
1019 Some(v2::struct_member::Member::Reserved(reserved)) => {
1020 Some(i64::from(reserved.ordinal))
1021 }
1022 _ => None,
1023 })
1024 .collect()
1025}
1026
1027pub(crate) fn union_slots(def: &v2::UnionDef) -> Vec<(String, i64)> {
1029 def.arms
1030 .iter()
1031 .map(|arm| (arm.name.clone(), i64::from(arm.ordinal)))
1032 .collect()
1033}
1034
1035fn union_reserved(def: &v2::UnionDef) -> Vec<i64> {
1036 def.reserved
1037 .iter()
1038 .map(|reserved| i64::from(reserved.ordinal))
1039 .collect()
1040}
1041
1042fn value_slots(values: &[v2::EnumValue]) -> Vec<(String, i64)> {
1045 values
1046 .iter()
1047 .map(|value| (value.name.clone(), value.value))
1048 .collect()
1049}
1050
1051fn reserved_values(reserved: &[v2::Reserved]) -> Vec<i64> {
1052 reserved.iter().filter_map(|entry| entry.value).collect()
1053}
1054
1055pub fn category_from_word(word: &str) -> Option<Category> {
1062 crate::CATEGORIES
1063 .into_iter()
1064 .find(|category| crate::category_word(*category) == word)
1065}
1066
1067#[deny(
1077 clippy::wildcard_enum_match_arm,
1078 clippy::match_wildcard_for_single_variants
1079)]
1080pub fn explain(category: Category) -> &'static str {
1081 match category {
1082 Category::DeclAdded => concat!(
1083 "A declaration, interface, or service present only in the new snapshot.\n",
1084 " compatible a new package-level decl, interface, or service; an enum\n",
1085 " value appended above every live and retired number; a\n",
1086 " union arm appended at the end of the body (typl 7.4,\n",
1087 " append-only); a struct field appended at the end of the body\n",
1088 " when it is optional, or when it is a scalar, enum, or enum set\n",
1089 " whose type allows the value 0\n",
1090 " breaking a member inserted below the highest slot ever used, a member\n",
1091 " taking a retired number, any addition that moves a surviving\n",
1092 " member's slot, or any arm of a result union (ADR-0008 d4); a\n",
1093 " non-optional struct field appended whose type does not allow\n",
1094 " 0 — a string, bytes, struct, union, tuple, array, or map, a\n",
1095 " scalar whose range or step excludes 0, an enum with no zero\n",
1096 " member, or a type the diff cannot resolve (ridl diff\n",
1097 " resolves ridl.std types). A reader of the new version\n",
1098 " refuses every payload of the old one, which does not carry\n",
1099 " the field (typl 7.4). Declare the new field optional instead\n",
1100 " (`field : T?`): an absent optional field reads as absent\n",
1101 " note an interface is matched by its interfaces.lock number (lock\n",
1102 " design 7): a frozen number the old side never held is a new\n",
1103 " interface, and so is every provisional one — a declaration\n",
1104 " with no lock entry carries no identity, and a rename keeps its\n",
1105 " number, so it is never a renamed interface (interface_renamed)"
1106 ),
1107 Category::DeclRemoved => concat!(
1108 "A declaration, interface, or service present only in the old snapshot.\n",
1109 " breaking a removed service, interface, decl, enum value, struct\n",
1110 " field, or union arm withdraws something a consumer compiled\n",
1111 " against\n",
1112 " note an interface is matched by its interfaces.lock number (lock\n",
1113 " design 7), so an interface here is one whose number is gone\n",
1114 " from the new side and not retired there — a lock line deleted\n",
1115 " by hand, or a number changed by hand, which is this row plus\n",
1116 " decl_added. A number the new side's lock retires is\n",
1117 " interface_retired instead, and `ridl baseline` refuses to\n",
1118 " publish the removal of a number it does not find retired\n",
1119 " (RIDL-412)\n",
1120 " caveat a composite member retired the sanctioned way — replaced by\n",
1121 " a `reserved` tombstone in its own slot (typl 7.4) — is also\n",
1122 " reported breaking today. The body comparison is keyed on\n",
1123 " member names and does not read the `reserved` list, so it\n",
1124 " cannot yet tell that retirement from a bare deletion. This\n",
1125 " errs on the safe side; carried as debt, see the note on\n",
1126 " `diff_composite`. The interaction-level tombstone IS\n",
1127 " recognised — see interaction_retired"
1128 ),
1129 Category::InterfaceRenamed => concat!(
1130 "An interface whose interfaces.lock number is the same on both sides and\n",
1131 "whose name changed.\n",
1132 " compatible always — the number is the interface's identity and its\n",
1133 " routing key (lock design 7, rsdl decision D-7), so nothing\n",
1134 " moves on the wire. Visible in source: a rename changes the\n",
1135 " generated identity-table names in both wire backends, which\n",
1136 " is why the text report lists it under the heading\n",
1137 " \"compatible on the wire, visible in source\". The path\n",
1138 " carries the new name and the detail old -> new; changes\n",
1139 " inside the interface are reported under the new name and\n",
1140 " classified as any other interface's\n",
1141 " note a provisional number is no identity: a declaration with no\n",
1142 " lock entry is never matched, so a rename the lock does not\n",
1143 " record is decl_removed plus decl_added, breaking, until\n",
1144 " `ridl lock <pkg> --rename Old=New` records it"
1145 ),
1146 Category::InterfaceRetired => concat!(
1147 "An interface whose number the old snapshot held is gone from the new\n",
1148 "snapshot, whose interfaces.lock retires it.\n",
1149 " compatible always — the sanctioned removal of an interface: the entry\n",
1150 " keeps its number forever with the word `retired`, so the\n",
1151 " number is never allocated again (lock design 4 and 7, rsdl\n",
1152 " decision D-7). A number gone with no retired entry is\n",
1153 " decl_removed, breaking, and `ridl baseline` refuses to publish\n",
1154 " it (RIDL-412)"
1155 ),
1156 Category::MemberReordered => concat!(
1157 "A surviving composite member whose slot in the body changed.\n",
1158 " breaking always — a struct field or union arm takes its wire\n",
1159 " identity from its ordinal, its 1-based place in the body\n",
1160 " counting tombstones (typl 7.4), so a member whose ordinal\n",
1161 " changed has a new wire identity; the detail carries the old\n",
1162 " and new ordinal. An enum value or enum-set bit carries an\n",
1163 " explicit number instead (typl 8, 9), but the walk compares\n",
1164 " positions, not those numbers, so a textual reorder of an\n",
1165 " enum or enum-set body is reported breaking as well,\n",
1166 " conservatively, even when no number changed; the detail\n",
1167 " carries the old and new position\n",
1168 " note reported only when both bodies hold the same member names:\n",
1169 " a reorder in the same edit as an addition or a removal is\n",
1170 " reported through decl_added or decl_removed alone. A reorder\n",
1171 " in the same edit as an in-place change to the body is\n",
1172 " reported with constraint_changed on the container as well"
1173 ),
1174 Category::InteractionAppended => concat!(
1175 "An interaction added after every slot that existed before.\n",
1176 " compatible the slot it takes was never occupied\n",
1177 " breaking the slot was freed by an untombstoned removal and is now\n",
1178 " reused by a new name — a reused wire identity (ADR-0008 d14)"
1179 ),
1180 Category::InteractionInserted => concat!(
1181 "An interaction added before the end of the interface body.\n",
1182 " breaking always — every later ordinal shifts, and the ordinal is the\n",
1183 " transport identity (ridl 11)"
1184 ),
1185 Category::InteractionReordered => concat!(
1186 "A surviving interaction whose relative order in the body changed.\n",
1187 " breaking always — a reorder shifts wire identities (ridl 11)"
1188 ),
1189 Category::InteractionRemoved => concat!(
1190 "An interaction removed without a `reserved` tombstone holding its slot.\n",
1191 " breaking always — the freed ordinal is reusable, so the wire identity\n",
1192 " is no longer permanent (ridl 11)"
1193 ),
1194 Category::InteractionRetired => concat!(
1195 "An interaction retired to a `reserved` tombstone in its own ordinal slot.\n",
1196 " compatible always — the sanctioned retirement: the slot stays occupied\n",
1197 " and every later ordinal holds (ridl 11)"
1198 ),
1199 Category::KindChanged => concat!(
1200 "An interaction whose kind changed (signal, event, command, query, fixed).\n",
1201 " breaking any direction — the kind selects the transport shape"
1202 ),
1203 Category::PayloadChanged => concat!(
1204 "A signal, event, or fixed payload type changed.\n",
1205 " breaking any direction, a stream added or removed included"
1206 ),
1207 Category::ReturnChanged => concat!(
1208 "A query return shape changed.\n",
1209 " breaking any direction — an ok-arm change, an error arm added, removed,\n",
1210 " or retyped, a stream added or removed, or any other change to\n",
1211 " the synthesized inline `T | E` transport identity (ADR-0008 d4:\n",
1212 " interface + interaction ordinal + ordered arm types)"
1213 ),
1214 Category::ParamsChanged => concat!(
1215 "A command or query parameter list changed.\n",
1216 " breaking any direction — a parameter added, removed, renamed, retyped,\n",
1217 " or a stream added or removed on one"
1218 ),
1219 Category::TimingChanged => concat!(
1220 "A signal or event resolved timing changed (ADR-0008 d12).\n",
1221 " compatible min raised (a higher rate floor) or max lowered (a tighter\n",
1222 " staleness bound) with the mode unchanged; default_applied\n",
1223 " flipped over identical resolved bounds — a default made\n",
1224 " explicit\n",
1225 " breaking min lowered, max raised, a bound added where none was, a bound\n",
1226 " removed, or the strict-periodic/range mode flipped\n",
1227 " note editing `[defaults].timing` needs no special rule: diff\n",
1228 " compares resolved bounds, so it surfaces here on every\n",
1229 " defaulted interaction (ridl 9.1)"
1230 ),
1231 Category::RpcBoundChanged => concat!(
1232 "A command or query declared RPC bound changed (ADR-0015 d8).\n",
1233 " compatible min lowered (the caller may call more often) or max lowered\n",
1234 " (a stronger provider promise), with the mode unchanged\n",
1235 " breaking min raised — on an RPC, min is the call throttle and\n",
1236 " constrains the caller, so raising it withdraws a call rate\n",
1237 " the caller was entitled to use; max raised (a weaker provider\n",
1238 " promise); a bound added or removed, the whole annotation\n",
1239 " included\n",
1240 " note the min direction is the inverse of timing_changed's, which\n",
1241 " is why this is a category of its own rather than a branch:\n",
1242 " a missed branch would inherit the signal rule and call a\n",
1243 " raised RPC min compatible (ADR-0012 d9, fail closed).\n",
1244 " RPC bounds are never defaulted (ridl 9.1 does not apply)"
1245 ),
1246 Category::ContractChanged => concat!(
1247 "A command or query require/ensure clause set changed (ridl 13).\n",
1248 " compatible a require removed, or an ensure added\n",
1249 " breaking a require added or its text changed, or an ensure removed or\n",
1250 " its text changed. Clause text is compared verbatim: the\n",
1251 " classifier does not prove that one clause implies another\n",
1252 " (ADR-0008 d14)"
1253 ),
1254 Category::WidthChanged => concat!(
1255 "A derived wire width or scalar backing changed.\n",
1256 " breaking any IntWidth or FloatWidth change, uint64 versus int64\n",
1257 " included — the resolved width is part of the contract\n",
1258 " (typl 4.2, 5.6)"
1259 ),
1260 Category::ConstraintChanged => concat!(
1261 "A scalar constraint changed, or a composite body changed in place.\n",
1262 " compatible widened — min lowered, max raised, a length bound loosened,\n",
1263 " a step removed, a match pattern removed (by literal or by\n",
1264 " named constant), or the whole constraint dropped so the\n",
1265 " value is unbounded again\n",
1266 " breaking narrowed — min raised, max lowered, a length bound\n",
1267 " tightened, a step added or changed (divisibility is never\n",
1268 " proved), a match pattern added or rewritten (by literal or\n",
1269 " by named constant), or a constraint appearing where there\n",
1270 " was none, which bounds a previously unbounded value. A\n",
1271 " composite body changed in place is breaking: the walk\n",
1272 " does not say which member changed inside it\n",
1273 " note each facet is judged on its own and any one narrowing\n",
1274 " decides the change, so a mixed edit is breaking on the half\n",
1275 " that narrows. A widening that flips the resolved wire width\n",
1276 " is separately reported as width_changed, which is always\n",
1277 " breaking (typl 5.6)"
1278 ),
1279 Category::InitChanged => concat!(
1280 "A declared or resolved init value changed.\n",
1281 " breaking any direction — the init is the value a consumer reads before\n",
1282 " the first publish, so it is part of the contract (ridl 9.1)"
1283 ),
1284 Category::ReservedNameRedeclared => concat!(
1285 "A name retired by a `reserved` tombstone is live again — an interaction\n",
1286 "inside an interface body.\n",
1287 " breaking always — a retired identity is never reused (ridl 11,\n",
1288 " RIDL-401)"
1289 ),
1290 Category::ServiceChanged => concat!(
1291 "A service switched between the named list and an inline shape.\n",
1292 " breaking always — extraction rewrites the transport identity of every\n",
1293 " fallible query in the shape: an inline shape derives it from\n",
1294 " the service's dotted name, a named interface from its own name\n",
1295 " (ADR-0008 d4, ADR-0015 d15). A changed list is not this\n",
1296 " category: it is read as a set by the service_interface_*\n",
1297 " rows (ADR-0015 d19, as amended 2026-09-15)"
1298 ),
1299 Category::ServiceInterfaceAdded => concat!(
1300 "An interface joined a service's set of interfaces.\n",
1301 " compatible always — nothing that existed moved: an interface's number\n",
1302 " comes from its package's interfaces.lock, not from its place\n",
1303 " in the list, and the routing key does not contain the service\n",
1304 " (ADR-0015 d19, as amended 2026-09-15)"
1305 ),
1306 Category::ServiceInterfaceRemoved => concat!(
1307 "An interface left a service's set of interfaces.\n",
1308 " compatible always — on the wire: the routing key does not contain the\n",
1309 " service, so no identity moves, and a split into two services\n",
1310 " is a removal plus an addition. Visible in source: the\n",
1311 " service.member addresses of that interface stop resolving\n",
1312 " under this service, which is why the text report lists it\n",
1313 " under the heading \"compatible on the wire, visible in\n",
1314 " source\". A consumer that loses its only provider is a\n",
1315 " wiring error for rsdl, not a package diff (ADR-0015 d19, as\n",
1316 " amended 2026-09-15)"
1317 ),
1318 Category::DocOnly => concat!(
1319 "Only doc comment, labels, or deprecation metadata changed.\n",
1320 " compatible always — none of it reaches a consumer's build.\n",
1321 " Visibility is NOT in this category: see\n",
1322 " visibility_changed"
1323 ),
1324 Category::VisibilityChanged => concat!(
1325 "The visibility a declaration is published at changed.\n",
1326 " compatible internal -> public: the declaration is offered to more\n",
1327 " consumers than before\n",
1328 " breaking public -> internal: `internal` maps to the target's\n",
1329 " package-private mechanism — Rust `pub(crate)`, a\n",
1330 " non-exported TypeScript member (ADR-0002 8) — so the\n",
1331 " declaration disappears from every out-of-package\n",
1332 " consumer's build. The wire layout does not move, but the\n",
1333 " consumer-visible guarantee narrows (ADR-0008 d14). Any\n",
1334 " direction involving an unset visibility is breaking"
1335 ),
1336 }
1337}