1#![forbid(unsafe_code)]
2#![cfg_attr(not(feature = "std"), no_std)]
3#![doc = include_str!("../README.md")]
4
5extern crate alloc;
6
7use alloc::boxed::Box;
8use alloc::format;
9use alloc::string::{String, ToString};
10use alloc::vec;
11use alloc::vec::Vec;
12use core::fmt;
13use serde::{Deserialize, Serialize};
14use sha2::{Digest, Sha256};
15
16pub const CURRENT_CONTRACT_FORMAT_VERSION: ContractFormatVersion = ContractFormatVersion::new(1, 3);
18
19#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
24pub struct ContractFormatVersion {
25 pub major: u16,
26 pub minor: u16,
27}
28
29impl ContractFormatVersion {
30 #[must_use]
31 pub const fn new(major: u16, minor: u16) -> Self {
32 Self { major, minor }
33 }
34}
35
36impl Default for ContractFormatVersion {
37 fn default() -> Self {
38 CURRENT_CONTRACT_FORMAT_VERSION
39 }
40}
41
42impl fmt::Display for ContractFormatVersion {
43 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
44 write!(formatter, "{}.{}", self.major, self.minor)
45 }
46}
47
48#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
50pub struct ContractFingerprint {
51 pub format_version: ContractFormatVersion,
52 digest: [u8; 32],
53}
54
55impl ContractFingerprint {
56 #[must_use]
57 pub const fn new(format_version: ContractFormatVersion, digest: [u8; 32]) -> Self {
58 Self {
59 format_version,
60 digest,
61 }
62 }
63
64 #[must_use]
65 pub const fn as_bytes(&self) -> &[u8; 32] {
66 &self.digest
67 }
68}
69
70impl fmt::Display for ContractFingerprint {
71 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
72 write!(
73 formatter,
74 "blazingly-contract-v{}-sha256:",
75 self.format_version
76 )?;
77 for byte in self.digest {
78 write!(formatter, "{byte:02x}")?;
79 }
80 Ok(())
81 }
82}
83
84#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
86#[serde(rename_all = "snake_case")]
87pub enum CompatibilityImpact {
88 Breaking,
89 NonBreaking,
90 Metadata,
91}
92
93#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
95pub struct CompatibilityChange {
96 pub impact: CompatibilityImpact,
97 pub path: String,
98 pub code: String,
99 pub message: String,
100}
101
102impl CompatibilityChange {
103 #[must_use]
104 pub fn new(
105 impact: CompatibilityImpact,
106 path: impl Into<String>,
107 code: impl Into<String>,
108 message: impl Into<String>,
109 ) -> Self {
110 Self {
111 impact,
112 path: path.into(),
113 code: code.into(),
114 message: message.into(),
115 }
116 }
117}
118
119#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
121pub struct CompatibilityReport {
122 pub previous: ContractFingerprint,
123 pub current: ContractFingerprint,
124 pub changes: Vec<CompatibilityChange>,
125}
126
127impl CompatibilityReport {
128 #[must_use]
129 pub fn is_backward_compatible(&self) -> bool {
130 !self
131 .changes
132 .iter()
133 .any(|change| change.impact == CompatibilityImpact::Breaking)
134 }
135
136 pub fn breaking_changes(&self) -> impl Iterator<Item = &CompatibilityChange> {
137 self.changes
138 .iter()
139 .filter(|change| change.impact == CompatibilityImpact::Breaking)
140 }
141}
142
143#[derive(Clone, Copy, Debug, Default)]
145pub struct Compatibility;
146
147#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
149#[serde(transparent)]
150pub struct OperationId(String);
151
152impl OperationId {
153 pub fn new(value: impl Into<String>) -> Result<Self, InvalidOperationId> {
164 let value = value.into();
165 let valid = !value.is_empty()
166 && value
167 .bytes()
168 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_'));
169
170 if valid {
171 Ok(Self(value))
172 } else {
173 Err(InvalidOperationId { value })
174 }
175 }
176
177 #[must_use]
178 pub fn as_str(&self) -> &str {
179 &self.0
180 }
181}
182
183impl fmt::Display for OperationId {
184 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
185 formatter.write_str(self.as_str())
186 }
187}
188
189#[derive(Clone, Debug, Eq, PartialEq)]
191pub struct InvalidOperationId {
192 value: String,
193}
194
195impl InvalidOperationId {
196 #[must_use]
197 pub fn value(&self) -> &str {
198 &self.value
199 }
200}
201
202impl fmt::Display for InvalidOperationId {
203 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
204 write!(
205 formatter,
206 "operation id {:?} must contain only ASCII letters, digits, '.', '-' or '_'",
207 self.value
208 )
209 }
210}
211
212#[cfg(feature = "std")]
213impl std::error::Error for InvalidOperationId {}
214
215#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
217#[serde(rename_all = "snake_case")]
218pub enum SchemaKind {
219 String,
220 Binary,
221 Integer,
222 Number,
223 Boolean,
224 Array(Box<SchemaKind>),
225 Object,
226 Any,
227}
228
229#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
231#[serde(rename_all = "snake_case")]
232pub enum ValidationRule {
233 MinLength(usize),
234 MaxLength(usize),
235 Email,
236 Alias(String),
238 Custom(String),
240 Nested,
242}
243
244#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
246pub struct FieldDescriptor {
247 pub name: String,
248 pub required: bool,
249 pub ty: TypeDescriptor,
250 pub validation: Vec<ValidationRule>,
251}
252
253impl FieldDescriptor {
254 #[must_use]
255 pub fn new(
256 name: impl Into<String>,
257 required: bool,
258 ty: TypeDescriptor,
259 validation: Vec<ValidationRule>,
260 ) -> Self {
261 Self {
262 name: name.into(),
263 required,
264 ty,
265 validation,
266 }
267 }
268}
269
270#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
272pub struct ModelDescriptor {
273 pub name: String,
274 pub fields: Vec<FieldDescriptor>,
275}
276
277impl ModelDescriptor {
278 #[must_use]
279 pub fn new(name: impl Into<String>, fields: Vec<FieldDescriptor>) -> Self {
280 Self {
281 name: name.into(),
282 fields,
283 }
284 }
285}
286
287#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
289pub struct TypeDescriptor {
290 pub rust_name: String,
291 pub schema: SchemaKind,
292 pub model: Option<Box<ModelDescriptor>>,
293 #[serde(default)]
299 pub items: Option<Box<TypeDescriptor>>,
300 #[serde(default)]
308 pub constraints: Vec<ValidationRule>,
309}
310
311impl TypeDescriptor {
312 #[must_use]
313 pub fn new(rust_name: impl Into<String>) -> Self {
314 Self {
315 rust_name: rust_name.into(),
316 schema: SchemaKind::Any,
317 model: None,
318 items: None,
319 constraints: Vec::new(),
320 }
321 }
322
323 #[must_use]
324 pub fn scalar(rust_name: impl Into<String>, schema: SchemaKind) -> Self {
325 Self {
326 rust_name: rust_name.into(),
327 schema,
328 model: None,
329 items: None,
330 constraints: Vec::new(),
331 }
332 }
333
334 #[must_use]
335 pub fn model(model: ModelDescriptor) -> Self {
336 Self {
337 rust_name: model.name.clone(),
338 schema: SchemaKind::Object,
339 model: Some(Box::new(model)),
340 items: None,
341 constraints: Vec::new(),
342 }
343 }
344
345 #[must_use]
347 pub fn with_constraints(mut self, constraints: Vec<ValidationRule>) -> Self {
348 self.constraints = constraints;
349 self
350 }
351}
352
353pub trait ApiModel {
355 fn model_descriptor() -> ModelDescriptor;
356
357 fn validate(&self) -> Result<(), ValidationErrors>;
363}
364
365pub trait ApiSchema {
367 fn type_descriptor() -> TypeDescriptor;
368
369 fn validate_input(&self) -> Result<(), ValidationErrors> {
378 Ok(())
379 }
380}
381
382impl<T: ApiModel> ApiSchema for T {
383 fn type_descriptor() -> TypeDescriptor {
384 TypeDescriptor::model(T::model_descriptor())
385 }
386
387 fn validate_input(&self) -> Result<(), ValidationErrors> {
388 self.validate()
389 }
390}
391
392impl ApiSchema for String {
393 fn type_descriptor() -> TypeDescriptor {
394 TypeDescriptor::scalar("String", SchemaKind::String)
395 }
396}
397
398impl ApiSchema for &str {
399 fn type_descriptor() -> TypeDescriptor {
400 TypeDescriptor::scalar("&str", SchemaKind::String)
401 }
402}
403
404impl ApiSchema for bool {
405 fn type_descriptor() -> TypeDescriptor {
406 TypeDescriptor::scalar("bool", SchemaKind::Boolean)
407 }
408}
409
410macro_rules! integer_schemas {
411 ($($type:ty),+ $(,)?) => {
412 $(
413 impl ApiSchema for $type {
414 fn type_descriptor() -> TypeDescriptor {
415 TypeDescriptor::scalar(stringify!($type), SchemaKind::Integer)
416 }
417 }
418 )+
419 };
420}
421
422integer_schemas!(
423 u8, u16, u32, u64, u128, usize, i8, i16, i32, i64, i128, isize
424);
425
426macro_rules! number_schemas {
427 ($($type:ty),+ $(,)?) => {
428 $(
429 impl ApiSchema for $type {
430 fn type_descriptor() -> TypeDescriptor {
431 TypeDescriptor::scalar(stringify!($type), SchemaKind::Number)
432 }
433 }
434 )+
435 };
436}
437
438number_schemas!(f32, f64);
439
440impl<T: ApiSchema> ApiSchema for Vec<T> {
441 fn type_descriptor() -> TypeDescriptor {
442 let item = T::type_descriptor();
443 TypeDescriptor {
444 rust_name: alloc::format!("Vec<{}>", item.rust_name),
445 schema: SchemaKind::Array(Box::new(item.schema.clone())),
446 model: None,
447 items: Some(Box::new(item)),
448 constraints: Vec::new(),
449 }
450 }
451}
452
453impl<T: ApiSchema> ApiSchema for Option<T> {
454 fn type_descriptor() -> TypeDescriptor {
455 T::type_descriptor()
456 }
457}
458
459#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
461pub struct FieldViolation {
462 pub field: String,
463 pub code: String,
464 pub message: String,
465}
466
467#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
469pub struct ValidationErrors {
470 violations: Vec<FieldViolation>,
471}
472
473#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
475pub struct OperationFailure {
476 pub status: u16,
477 pub code: String,
478 pub message: String,
479 pub details: Option<Vec<u8>>,
480 pub headers: Vec<ResponseHeader>,
481}
482
483impl OperationFailure {
484 #[must_use]
485 pub fn new(status: u16, code: impl Into<String>, message: impl Into<String>) -> Self {
486 Self {
487 status,
488 code: code.into(),
489 message: message.into(),
490 details: None,
491 headers: Vec::new(),
492 }
493 }
494
495 #[must_use]
496 pub fn with_details(mut self, details: Vec<u8>) -> Self {
497 self.details = Some(details);
498 self
499 }
500
501 #[must_use]
502 pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
503 self.headers.push(ResponseHeader::new(name, value));
504 self
505 }
506}
507
508#[derive(Clone, Debug, Eq, PartialEq)]
510pub struct ResponseBuildError {
511 pub code: String,
512 pub message: String,
513}
514
515impl ResponseBuildError {
516 #[must_use]
517 pub fn serialization_failed() -> Self {
518 Self {
519 code: "serialization_failed".to_string(),
520 message: "operation response could not be serialized".to_string(),
521 }
522 }
523}
524
525pub trait ApiError {
527 fn response_descriptors() -> Vec<ResponseDescriptor>;
528
529 fn into_failure(self) -> Result<OperationFailure, ResponseBuildError>;
536}
537
538impl ValidationErrors {
539 #[must_use]
540 pub const fn new() -> Self {
541 Self {
542 violations: Vec::new(),
543 }
544 }
545
546 pub fn push(
547 &mut self,
548 field: impl Into<String>,
549 code: impl Into<String>,
550 message: impl Into<String>,
551 ) {
552 self.violations.push(FieldViolation {
553 field: field.into(),
554 code: code.into(),
555 message: message.into(),
556 });
557 }
558
559 #[must_use]
560 pub fn is_empty(&self) -> bool {
561 self.violations.is_empty()
562 }
563
564 #[must_use]
565 pub fn violations(&self) -> &[FieldViolation] {
566 &self.violations
567 }
568}
569
570#[doc(hidden)]
572#[must_use]
573pub fn is_email(value: &str) -> bool {
574 let Some((local, domain)) = value.split_once('@') else {
575 return false;
576 };
577 !local.is_empty()
578 && !domain.is_empty()
579 && domain.contains('.')
580 && !value.chars().any(char::is_whitespace)
581}
582
583#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
585pub struct ResponseDescriptor {
586 pub status: u16,
587 pub body: Option<TypeDescriptor>,
588 pub error_code: Option<String>,
589 pub error_message: Option<String>,
590 pub headers: Vec<ResponseHeader>,
591}
592
593#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
595pub struct ResponseHeader {
596 pub name: String,
597 pub value: String,
598}
599
600impl ResponseHeader {
601 #[must_use]
602 pub fn new(name: impl Into<String>, value: impl Into<String>) -> Self {
603 Self {
604 name: name.into(),
605 value: value.into(),
606 }
607 }
608}
609
610#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
612#[serde(rename_all = "snake_case")]
613pub enum InputSource {
614 Path,
615 Query,
616 Header,
617 Cookie,
618 Json,
619 Form,
620 Multipart,
621 File,
622 Stream,
624}
625
626#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
628pub struct InputDescriptor {
629 pub name: String,
630 pub source: InputSource,
631 pub required: bool,
632 pub ty: TypeDescriptor,
633}
634
635impl InputDescriptor {
636 #[must_use]
637 pub fn new(
638 name: impl Into<String>,
639 source: InputSource,
640 required: bool,
641 ty: TypeDescriptor,
642 ) -> Self {
643 Self {
644 name: name.into(),
645 source,
646 required,
647 ty,
648 }
649 }
650}
651
652impl ResponseDescriptor {
653 #[must_use]
654 pub fn success(status: u16, body: Option<TypeDescriptor>) -> Self {
655 Self {
656 status,
657 body,
658 error_code: None,
659 error_message: None,
660 headers: Vec::new(),
661 }
662 }
663
664 #[must_use]
665 pub fn error(
666 status: u16,
667 code: impl Into<String>,
668 message: impl Into<String>,
669 body: Option<TypeDescriptor>,
670 ) -> Self {
671 Self {
672 status,
673 body,
674 error_code: Some(code.into()),
675 error_message: Some(message.into()),
676 headers: Vec::new(),
677 }
678 }
679
680 #[must_use]
681 pub fn with_headers(mut self, headers: Vec<ResponseHeader>) -> Self {
682 self.headers = headers;
683 self
684 }
685}
686
687#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
689#[serde(rename_all = "snake_case")]
690pub enum OperationRisk {
691 #[default]
692 Read,
693 Write,
694 Destructive,
695}
696
697#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
699#[serde(rename_all = "snake_case")]
700pub enum Confirmation {
701 #[default]
702 Never,
703 Required,
704}
705
706#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
708pub struct AgentPolicy {
709 pub risk: OperationRisk,
710 pub confirmation: Confirmation,
711 pub idempotent: bool,
712}
713
714#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
716#[serde(rename_all = "snake_case")]
717pub enum OutputExposure {
718 #[default]
719 Full,
720 SummaryOnly,
721 None,
722}
723
724#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
726pub struct McpToolDescriptor {
727 pub name: String,
728 pub description: String,
729 pub expose_output: OutputExposure,
730}
731
732impl McpToolDescriptor {
733 #[must_use]
734 pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
735 Self {
736 name: name.into(),
737 description: description.into(),
738 expose_output: OutputExposure::Full,
739 }
740 }
741
742 #[must_use]
743 pub const fn with_output_exposure(mut self, exposure: OutputExposure) -> Self {
744 self.expose_output = exposure;
745 self
746 }
747}
748
749#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
751pub struct DependencyDescriptor {
752 pub rust_name: String,
753}
754
755impl DependencyDescriptor {
756 #[must_use]
757 pub fn new(rust_name: impl Into<String>) -> Self {
758 Self {
759 rust_name: rust_name.into(),
760 }
761 }
762}
763
764#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
766#[serde(rename_all = "snake_case")]
767pub enum SecurityLocation {
768 Header,
769 Query,
770 Cookie,
771}
772
773#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
775#[serde(tag = "type", rename_all = "snake_case")]
776pub enum SecuritySchemeKind {
777 ApiKey {
778 location: SecurityLocation,
779 name: String,
780 },
781 Http {
782 scheme: String,
783 bearer_format: Option<String>,
784 },
785 OAuth2 {
786 authorization_url: Option<String>,
787 token_url: Option<String>,
788 scopes: Vec<String>,
789 },
790 OpenIdConnect {
791 discovery_url: String,
792 },
793 MutualTls,
794}
795
796#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
798pub struct SecuritySchemeDescriptor {
799 pub name: String,
800 pub description: Option<String>,
801 pub kind: SecuritySchemeKind,
802}
803
804impl SecuritySchemeDescriptor {
805 #[must_use]
806 pub fn new(name: impl Into<String>, kind: SecuritySchemeKind) -> Self {
807 Self {
808 name: name.into(),
809 description: None,
810 kind,
811 }
812 }
813
814 #[must_use]
815 pub fn with_description(mut self, description: impl Into<String>) -> Self {
816 self.description = Some(description.into());
817 self
818 }
819}
820
821#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
823pub struct SecurityRequirement {
824 pub scheme: String,
825 pub scopes: Vec<String>,
826}
827
828impl SecurityRequirement {
829 #[must_use]
830 pub fn new(scheme: impl Into<String>) -> Self {
831 Self {
832 scheme: scheme.into(),
833 scopes: Vec::new(),
834 }
835 }
836
837 #[must_use]
838 pub fn with_scopes(mut self, scopes: Vec<String>) -> Self {
839 self.scopes = scopes;
840 self
841 }
842}
843
844#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
846pub struct OperationContract {
847 #[serde(default)]
848 pub format_version: ContractFormatVersion,
849 pub id: OperationId,
850 pub summary: String,
851 pub input: Option<TypeDescriptor>,
855 pub inputs: Vec<InputDescriptor>,
856 #[serde(default)]
857 pub dependencies: Vec<DependencyDescriptor>,
858 #[serde(default)]
859 pub security: Vec<SecurityRequirement>,
860 pub responses: Vec<ResponseDescriptor>,
861 #[serde(default)]
862 pub agent: AgentPolicy,
863 #[serde(default)]
864 pub mcp: Option<McpToolDescriptor>,
865}
866
867impl OperationContract {
868 pub fn new(
875 id: impl Into<String>,
876 summary: impl Into<String>,
877 input: Option<TypeDescriptor>,
878 responses: Vec<ResponseDescriptor>,
879 ) -> Result<Self, InvalidOperationId> {
880 let inputs = input
881 .as_ref()
882 .map(|input| {
883 vec![InputDescriptor::new(
884 "body",
885 InputSource::Json,
886 true,
887 input.clone(),
888 )]
889 })
890 .unwrap_or_default();
891 Ok(Self {
892 format_version: CURRENT_CONTRACT_FORMAT_VERSION,
893 id: OperationId::new(id)?,
894 summary: summary.into(),
895 input,
896 inputs,
897 dependencies: Vec::new(),
898 security: Vec::new(),
899 responses,
900 agent: AgentPolicy::default(),
901 mcp: None,
902 })
903 }
904
905 #[must_use]
906 pub fn with_agent_policy(mut self, policy: AgentPolicy) -> Self {
907 self.agent = policy;
908 self
909 }
910
911 #[must_use]
912 pub fn with_inputs(mut self, inputs: Vec<InputDescriptor>) -> Self {
913 self.input = inputs
914 .iter()
915 .find(|input| input.source == InputSource::Json)
916 .map(|input| input.ty.clone());
917 self.inputs = inputs;
918 self
919 }
920
921 #[must_use]
922 pub fn with_dependencies(mut self, dependencies: Vec<DependencyDescriptor>) -> Self {
923 self.dependencies = dependencies;
924 self
925 }
926
927 #[must_use]
928 pub fn with_security(mut self, security: Vec<SecurityRequirement>) -> Self {
929 self.security = security;
930 self
931 }
932
933 #[must_use]
934 pub fn with_mcp_tool(mut self, tool: McpToolDescriptor) -> Self {
935 self.mcp = Some(tool);
936 self
937 }
938
939 #[must_use]
944 pub fn canonical_bytes(&self) -> Vec<u8> {
945 let mut encoder = CanonicalEncoder::new();
946 encoder.bytes(b"blazingly.operation-contract");
947 encoder.u16(self.format_version.major);
948 encoder.u16(self.format_version.minor);
949 encoder.operation(self);
950 encoder.finish()
951 }
952
953 #[must_use]
955 pub fn fingerprint(&self) -> ContractFingerprint {
956 let digest = Sha256::digest(self.canonical_bytes());
957 let mut bytes = [0_u8; 32];
958 bytes.copy_from_slice(&digest);
959 ContractFingerprint::new(self.format_version, bytes)
960 }
961}
962
963struct CanonicalEncoder {
964 output: Vec<u8>,
965}
966
967impl CanonicalEncoder {
968 fn new() -> Self {
969 Self { output: Vec::new() }
970 }
971
972 fn finish(self) -> Vec<u8> {
973 self.output
974 }
975
976 fn u8(&mut self, value: u8) {
977 self.output.push(value);
978 }
979
980 fn u16(&mut self, value: u16) {
981 self.output.extend_from_slice(&value.to_be_bytes());
982 }
983
984 fn u64(&mut self, value: u64) {
985 self.output.extend_from_slice(&value.to_be_bytes());
986 }
987
988 fn bool(&mut self, value: bool) {
989 self.u8(u8::from(value));
990 }
991
992 fn bytes(&mut self, value: &[u8]) {
993 self.u64(value.len() as u64);
994 self.output.extend_from_slice(value);
995 }
996
997 fn string(&mut self, value: &str) {
998 self.bytes(value.as_bytes());
999 }
1000
1001 fn optional_string(&mut self, value: Option<&str>) {
1002 match value {
1003 Some(value) => {
1004 self.u8(1);
1005 self.string(value);
1006 }
1007 None => self.u8(0),
1008 }
1009 }
1010
1011 fn sorted<T>(&mut self, values: &[T], mut encode: impl FnMut(&mut Self, &T)) {
1013 let mut encoded = Vec::with_capacity(values.len());
1014 for value in values {
1015 let mut item = Self::new();
1016 encode(&mut item, value);
1017 encoded.push(item.finish());
1018 }
1019 encoded.sort();
1020
1021 self.u64(encoded.len() as u64);
1022 for item in encoded {
1023 self.bytes(&item);
1024 }
1025 }
1026
1027 fn operation(&mut self, contract: &OperationContract) {
1028 self.string(contract.id.as_str());
1029 self.string(&contract.summary);
1030 self.sorted(&contract.inputs, Self::input);
1031 self.sorted(&contract.dependencies, |encoder, dependency| {
1032 encoder.string(&dependency.rust_name);
1033 });
1034 self.sorted(&contract.security, Self::security_requirement);
1035 self.sorted(&contract.responses, Self::response);
1036 self.agent(&contract.agent);
1037 match &contract.mcp {
1038 Some(tool) => {
1039 self.u8(1);
1040 self.mcp(tool);
1041 }
1042 None => self.u8(0),
1043 }
1044 }
1045
1046 fn input(&mut self, input: &InputDescriptor) {
1047 self.u8(input_source_tag(input.source));
1048 if input.source == InputSource::Header {
1049 self.string(&input.name.to_ascii_lowercase());
1050 } else {
1051 self.string(&input.name);
1052 }
1053 self.bool(input.required);
1054 self.ty(&input.ty);
1055 }
1056
1057 fn ty(&mut self, ty: &TypeDescriptor) {
1058 self.string(&ty.rust_name);
1059 self.schema(&ty.schema);
1060 match &ty.model {
1061 Some(model) => {
1062 self.u8(1);
1063 self.model(model);
1064 }
1065 None => self.u8(0),
1066 }
1067 match &ty.items {
1068 Some(items) => {
1069 self.u8(1);
1070 self.ty(items);
1071 }
1072 None => self.u8(0),
1073 }
1074 self.sorted(&ty.constraints, Self::validation);
1075 }
1076
1077 fn schema(&mut self, schema: &SchemaKind) {
1078 match schema {
1079 SchemaKind::String => self.u8(0),
1080 SchemaKind::Binary => self.u8(1),
1081 SchemaKind::Integer => self.u8(2),
1082 SchemaKind::Number => self.u8(3),
1083 SchemaKind::Boolean => self.u8(4),
1084 SchemaKind::Array(item) => {
1085 self.u8(5);
1086 self.schema(item);
1087 }
1088 SchemaKind::Object => self.u8(6),
1089 SchemaKind::Any => self.u8(7),
1090 }
1091 }
1092
1093 fn model(&mut self, model: &ModelDescriptor) {
1094 self.string(&model.name);
1095 self.sorted(&model.fields, Self::field);
1096 }
1097
1098 fn field(&mut self, field: &FieldDescriptor) {
1099 self.string(&field.name);
1100 self.bool(field.required);
1101 self.ty(&field.ty);
1102 self.sorted(&field.validation, Self::validation);
1103 }
1104
1105 fn validation(&mut self, rule: &ValidationRule) {
1106 match rule {
1107 ValidationRule::MinLength(value) => {
1108 self.u8(0);
1109 self.u64(*value as u64);
1110 }
1111 ValidationRule::MaxLength(value) => {
1112 self.u8(1);
1113 self.u64(*value as u64);
1114 }
1115 ValidationRule::Email => self.u8(2),
1116 ValidationRule::Alias(alias) => {
1117 self.u8(3);
1118 self.string(alias);
1119 }
1120 ValidationRule::Custom(validator) => {
1121 self.u8(4);
1122 self.string(validator);
1123 }
1124 ValidationRule::Nested => self.u8(5),
1125 }
1126 }
1127
1128 fn response(&mut self, response: &ResponseDescriptor) {
1129 self.u16(response.status);
1130 match &response.body {
1131 Some(body) => {
1132 self.u8(1);
1133 self.ty(body);
1134 }
1135 None => self.u8(0),
1136 }
1137 self.optional_string(response.error_code.as_deref());
1138 self.optional_string(response.error_message.as_deref());
1139 self.sorted(&response.headers, |encoder, header| {
1140 encoder.string(&header.name.to_ascii_lowercase());
1141 encoder.string(&header.value);
1142 });
1143 }
1144
1145 fn security_requirement(&mut self, requirement: &SecurityRequirement) {
1146 self.string(&requirement.scheme);
1147 self.sorted(&requirement.scopes, |encoder, scope| encoder.string(scope));
1148 }
1149
1150 fn agent(&mut self, policy: &AgentPolicy) {
1151 self.u8(risk_tag(policy.risk));
1152 self.u8(confirmation_tag(policy.confirmation));
1153 self.bool(policy.idempotent);
1154 }
1155
1156 fn mcp(&mut self, tool: &McpToolDescriptor) {
1157 self.string(&tool.name);
1158 self.string(&tool.description);
1159 self.u8(exposure_tag(tool.expose_output));
1160 }
1161}
1162
1163impl Compatibility {
1164 #[must_use]
1167 pub fn compare(
1168 previous: &OperationContract,
1169 current: &OperationContract,
1170 ) -> CompatibilityReport {
1171 let previous_fingerprint = previous.fingerprint();
1172 let current_fingerprint = current.fingerprint();
1173 let mut report = CompatibilityReport {
1174 previous: previous_fingerprint,
1175 current: current_fingerprint,
1176 changes: Vec::new(),
1177 };
1178
1179 if previous_fingerprint == current_fingerprint {
1180 return report;
1181 }
1182
1183 compare_versions(previous, current, &mut report.changes);
1184 compare_identity(previous, current, &mut report.changes);
1185 compare_inputs(previous, current, &mut report.changes);
1186 compare_dependencies(previous, current, &mut report.changes);
1187 compare_security(previous, current, &mut report.changes);
1188 compare_responses(previous, current, &mut report.changes);
1189 compare_agent(previous, current, &mut report.changes);
1190 compare_mcp(previous, current, &mut report.changes);
1191
1192 report.changes.sort_by(|left, right| {
1193 (&left.path, &left.code, impact_tag(left.impact)).cmp(&(
1194 &right.path,
1195 &right.code,
1196 impact_tag(right.impact),
1197 ))
1198 });
1199 report
1200 }
1201}
1202
1203fn compare_versions(
1204 previous: &OperationContract,
1205 current: &OperationContract,
1206 changes: &mut Vec<CompatibilityChange>,
1207) {
1208 if previous.format_version.major != current.format_version.major {
1209 changes.push(change(
1210 CompatibilityImpact::Breaking,
1211 "format_version",
1212 "format_major_changed",
1213 format!(
1214 "canonical contract format changed from {} to {}",
1215 previous.format_version, current.format_version
1216 ),
1217 ));
1218 } else if previous.format_version.minor != current.format_version.minor {
1219 changes.push(change(
1220 CompatibilityImpact::Metadata,
1221 "format_version",
1222 "format_minor_changed",
1223 format!(
1224 "canonical contract format changed from {} to {}",
1225 previous.format_version, current.format_version
1226 ),
1227 ));
1228 }
1229}
1230
1231fn compare_identity(
1232 previous: &OperationContract,
1233 current: &OperationContract,
1234 changes: &mut Vec<CompatibilityChange>,
1235) {
1236 if previous.id != current.id {
1237 changes.push(change(
1238 CompatibilityImpact::Breaking,
1239 "id",
1240 "operation_id_changed",
1241 format!(
1242 "operation id changed from {} to {}",
1243 previous.id, current.id
1244 ),
1245 ));
1246 }
1247 if previous.summary != current.summary {
1248 changes.push(change(
1249 CompatibilityImpact::Metadata,
1250 "summary",
1251 "summary_changed",
1252 "operation summary changed",
1253 ));
1254 }
1255}
1256
1257fn compare_inputs(
1258 previous: &OperationContract,
1259 current: &OperationContract,
1260 changes: &mut Vec<CompatibilityChange>,
1261) {
1262 for old in &previous.inputs {
1263 let path = input_path(old);
1264 let Some(new) = current
1265 .inputs
1266 .iter()
1267 .find(|candidate| same_input_key(old, candidate))
1268 else {
1269 changes.push(change(
1270 CompatibilityImpact::Breaking,
1271 path,
1272 "input_removed",
1273 "an accepted operation input was removed",
1274 ));
1275 continue;
1276 };
1277
1278 match (old.required, new.required) {
1279 (false, true) => changes.push(change(
1280 CompatibilityImpact::Breaking,
1281 format!("{path}.required"),
1282 "input_became_required",
1283 "an optional input became required",
1284 )),
1285 (true, false) => changes.push(change(
1286 CompatibilityImpact::NonBreaking,
1287 format!("{path}.required"),
1288 "input_became_optional",
1289 "a required input became optional",
1290 )),
1291 _ => {}
1292 }
1293 compare_type(
1294 &old.ty,
1295 &new.ty,
1296 &format!("{path}.type"),
1297 TypeDirection::Input,
1298 changes,
1299 );
1300 }
1301
1302 for new in ¤t.inputs {
1303 if previous
1304 .inputs
1305 .iter()
1306 .any(|candidate| same_input_key(candidate, new))
1307 {
1308 continue;
1309 }
1310 changes.push(change(
1311 if new.required {
1312 CompatibilityImpact::Breaking
1313 } else {
1314 CompatibilityImpact::NonBreaking
1315 },
1316 input_path(new),
1317 if new.required {
1318 "required_input_added"
1319 } else {
1320 "optional_input_added"
1321 },
1322 if new.required {
1323 "a new required operation input was added"
1324 } else {
1325 "a new optional operation input was added"
1326 },
1327 ));
1328 }
1329}
1330
1331fn compare_dependencies(
1332 previous: &OperationContract,
1333 current: &OperationContract,
1334 changes: &mut Vec<CompatibilityChange>,
1335) {
1336 for old in &previous.dependencies {
1337 if !current
1338 .dependencies
1339 .iter()
1340 .any(|new| new.rust_name == old.rust_name)
1341 {
1342 changes.push(change(
1343 CompatibilityImpact::NonBreaking,
1344 format!("dependencies.{}", old.rust_name),
1345 "dependency_removed",
1346 "an operation dependency was removed",
1347 ));
1348 }
1349 }
1350 for new in ¤t.dependencies {
1351 if !previous
1352 .dependencies
1353 .iter()
1354 .any(|old| old.rust_name == new.rust_name)
1355 {
1356 changes.push(change(
1357 CompatibilityImpact::Breaking,
1358 format!("dependencies.{}", new.rust_name),
1359 "dependency_added",
1360 "a new operation dependency must be provided",
1361 ));
1362 }
1363 }
1364}
1365
1366fn compare_security(
1367 previous: &OperationContract,
1368 current: &OperationContract,
1369 changes: &mut Vec<CompatibilityChange>,
1370) {
1371 for old in &previous.security {
1372 let Some(new) = current
1373 .security
1374 .iter()
1375 .find(|candidate| candidate.scheme == old.scheme)
1376 else {
1377 changes.push(change(
1378 CompatibilityImpact::NonBreaking,
1379 format!("security.{}", old.scheme),
1380 "security_requirement_removed",
1381 "a security requirement was removed",
1382 ));
1383 continue;
1384 };
1385 compare_string_set(
1386 &old.scopes,
1387 &new.scopes,
1388 &format!("security.{}.scopes", old.scheme),
1389 "security_scope",
1390 CompatibilityImpact::Breaking,
1391 CompatibilityImpact::NonBreaking,
1392 changes,
1393 );
1394 }
1395 for new in ¤t.security {
1396 if !previous.security.iter().any(|old| old.scheme == new.scheme) {
1397 changes.push(change(
1398 CompatibilityImpact::Breaking,
1399 format!("security.{}", new.scheme),
1400 "security_requirement_added",
1401 "a new security requirement was added",
1402 ));
1403 }
1404 }
1405}
1406
1407fn compare_responses(
1408 previous: &OperationContract,
1409 current: &OperationContract,
1410 changes: &mut Vec<CompatibilityChange>,
1411) {
1412 for old in &previous.responses {
1413 let path = response_path(old);
1414 let Some(new) = current
1415 .responses
1416 .iter()
1417 .find(|candidate| same_response_key(old, candidate))
1418 else {
1419 changes.push(change(
1420 CompatibilityImpact::Breaking,
1421 path,
1422 "response_removed",
1423 "a declared operation response was removed",
1424 ));
1425 continue;
1426 };
1427
1428 match (&old.body, &new.body) {
1429 (Some(old), Some(new)) => compare_type(
1430 old,
1431 new,
1432 &format!("{path}.body"),
1433 TypeDirection::Output,
1434 changes,
1435 ),
1436 (Some(_), None) => changes.push(change(
1437 CompatibilityImpact::Breaking,
1438 format!("{path}.body"),
1439 "response_body_removed",
1440 "a declared response body was removed",
1441 )),
1442 (None, Some(_)) => changes.push(change(
1443 CompatibilityImpact::NonBreaking,
1444 format!("{path}.body"),
1445 "response_body_added",
1446 "a response body was added",
1447 )),
1448 (None, None) => {}
1449 }
1450
1451 if old.error_message != new.error_message {
1452 changes.push(change(
1453 CompatibilityImpact::Metadata,
1454 format!("{path}.error_message"),
1455 "error_message_changed",
1456 "a declared error message changed",
1457 ));
1458 }
1459 compare_response_headers(&old.headers, &new.headers, &path, changes);
1460 }
1461
1462 for new in ¤t.responses {
1463 if !previous
1464 .responses
1465 .iter()
1466 .any(|candidate| same_response_key(candidate, new))
1467 {
1468 changes.push(change(
1469 CompatibilityImpact::NonBreaking,
1470 response_path(new),
1471 "response_added",
1472 "a new operation response was declared",
1473 ));
1474 }
1475 }
1476}
1477
1478fn compare_response_headers(
1479 previous: &[ResponseHeader],
1480 current: &[ResponseHeader],
1481 response_path: &str,
1482 changes: &mut Vec<CompatibilityChange>,
1483) {
1484 for old in previous {
1485 let path = format!("{response_path}.headers.{}", old.name.to_ascii_lowercase());
1486 let Some(new) = current
1487 .iter()
1488 .find(|header| header.name.eq_ignore_ascii_case(&old.name))
1489 else {
1490 changes.push(change(
1491 CompatibilityImpact::Breaking,
1492 path,
1493 "response_header_removed",
1494 "a declared response header was removed",
1495 ));
1496 continue;
1497 };
1498 if old.value != new.value {
1499 changes.push(change(
1500 CompatibilityImpact::Breaking,
1501 path,
1502 "response_header_value_changed",
1503 "a declared response header value changed",
1504 ));
1505 }
1506 }
1507 for new in current {
1508 if !previous
1509 .iter()
1510 .any(|header| header.name.eq_ignore_ascii_case(&new.name))
1511 {
1512 changes.push(change(
1513 CompatibilityImpact::NonBreaking,
1514 format!("{response_path}.headers.{}", new.name.to_ascii_lowercase()),
1515 "response_header_added",
1516 "a new response header was declared",
1517 ));
1518 }
1519 }
1520}
1521
1522fn compare_agent(
1523 previous: &OperationContract,
1524 current: &OperationContract,
1525 changes: &mut Vec<CompatibilityChange>,
1526) {
1527 let previous_risk = risk_tag(previous.agent.risk);
1528 let current_risk = risk_tag(current.agent.risk);
1529 if previous_risk != current_risk {
1530 changes.push(change(
1531 if current_risk > previous_risk {
1532 CompatibilityImpact::Breaking
1533 } else {
1534 CompatibilityImpact::NonBreaking
1535 },
1536 "agent.risk",
1537 "agent_risk_changed",
1538 "agent invocation risk changed",
1539 ));
1540 }
1541
1542 if previous.agent.confirmation != current.agent.confirmation {
1543 changes.push(change(
1544 if current.agent.confirmation == Confirmation::Required {
1545 CompatibilityImpact::Breaking
1546 } else {
1547 CompatibilityImpact::NonBreaking
1548 },
1549 "agent.confirmation",
1550 "agent_confirmation_changed",
1551 "agent confirmation policy changed",
1552 ));
1553 }
1554
1555 if previous.agent.idempotent != current.agent.idempotent {
1556 changes.push(change(
1557 if previous.agent.idempotent {
1558 CompatibilityImpact::Breaking
1559 } else {
1560 CompatibilityImpact::NonBreaking
1561 },
1562 "agent.idempotent",
1563 "agent_idempotency_changed",
1564 "agent idempotency guarantee changed",
1565 ));
1566 }
1567}
1568
1569fn compare_mcp(
1570 previous: &OperationContract,
1571 current: &OperationContract,
1572 changes: &mut Vec<CompatibilityChange>,
1573) {
1574 match (&previous.mcp, ¤t.mcp) {
1575 (Some(old), Some(new)) => {
1576 if old.name != new.name {
1577 changes.push(change(
1578 CompatibilityImpact::Breaking,
1579 "mcp.name",
1580 "mcp_tool_name_changed",
1581 "MCP tool name changed",
1582 ));
1583 }
1584 if old.description != new.description {
1585 changes.push(change(
1586 CompatibilityImpact::Metadata,
1587 "mcp.description",
1588 "mcp_description_changed",
1589 "MCP tool description changed",
1590 ));
1591 }
1592 let old_exposure = exposure_tag(old.expose_output);
1593 let new_exposure = exposure_tag(new.expose_output);
1594 if old_exposure != new_exposure {
1595 changes.push(change(
1596 if new_exposure > old_exposure {
1597 CompatibilityImpact::Breaking
1598 } else {
1599 CompatibilityImpact::NonBreaking
1600 },
1601 "mcp.expose_output",
1602 "mcp_output_exposure_changed",
1603 "MCP output exposure policy changed",
1604 ));
1605 }
1606 }
1607 (Some(_), None) => changes.push(change(
1608 CompatibilityImpact::Breaking,
1609 "mcp",
1610 "mcp_tool_removed",
1611 "MCP tool projection was removed",
1612 )),
1613 (None, Some(_)) => changes.push(change(
1614 CompatibilityImpact::NonBreaking,
1615 "mcp",
1616 "mcp_tool_added",
1617 "MCP tool projection was added",
1618 )),
1619 (None, None) => {}
1620 }
1621}
1622
1623#[derive(Clone, Copy)]
1624enum TypeDirection {
1625 Input,
1626 Output,
1627}
1628
1629fn compare_type(
1630 previous: &TypeDescriptor,
1631 current: &TypeDescriptor,
1632 path: &str,
1633 direction: TypeDirection,
1634 changes: &mut Vec<CompatibilityChange>,
1635) {
1636 if previous.schema != current.schema {
1637 changes.push(change(
1638 CompatibilityImpact::Breaking,
1639 format!("{path}.schema"),
1640 "schema_changed",
1641 "wire schema changed",
1642 ));
1643 return;
1644 }
1645 if previous.rust_name != current.rust_name {
1646 changes.push(change(
1647 CompatibilityImpact::Metadata,
1648 format!("{path}.rust_name"),
1649 "rust_type_name_changed",
1650 "Rust type name changed without changing the wire shape",
1651 ));
1652 }
1653 compare_validation(
1654 &previous.constraints,
1655 ¤t.constraints,
1656 &format!("{path}.constraints"),
1657 direction,
1658 changes,
1659 );
1660
1661 match (&previous.items, ¤t.items) {
1662 (Some(old), Some(new)) => {
1663 compare_type(old, new, &format!("{path}.items"), direction, changes);
1664 }
1665 (Some(_), None) => changes.push(change(
1666 CompatibilityImpact::Breaking,
1667 format!("{path}.items"),
1668 "collection_item_contract_removed",
1669 "collection item contract precision was removed",
1670 )),
1671 (None, Some(_)) => changes.push(change(
1672 match direction {
1673 TypeDirection::Input => CompatibilityImpact::Breaking,
1674 TypeDirection::Output => CompatibilityImpact::NonBreaking,
1675 },
1676 format!("{path}.items"),
1677 "collection_item_contract_added",
1678 "collection item contract precision was added",
1679 )),
1680 (None, None) => {}
1681 }
1682
1683 match (&previous.model, ¤t.model) {
1684 (Some(old), Some(new)) => compare_model(old, new, path, direction, changes),
1685 (Some(_), None) => changes.push(change(
1686 CompatibilityImpact::Breaking,
1687 format!("{path}.model"),
1688 "model_contract_removed",
1689 "model contract precision was removed",
1690 )),
1691 (None, Some(_)) => changes.push(change(
1692 match direction {
1693 TypeDirection::Input => CompatibilityImpact::Breaking,
1694 TypeDirection::Output => CompatibilityImpact::NonBreaking,
1695 },
1696 format!("{path}.model"),
1697 "model_contract_added",
1698 "model contract precision was added",
1699 )),
1700 (None, None) => {}
1701 }
1702}
1703
1704fn compare_model(
1705 previous: &ModelDescriptor,
1706 current: &ModelDescriptor,
1707 path: &str,
1708 direction: TypeDirection,
1709 changes: &mut Vec<CompatibilityChange>,
1710) {
1711 if previous.name != current.name {
1712 changes.push(change(
1713 CompatibilityImpact::Metadata,
1714 format!("{path}.model.name"),
1715 "model_name_changed",
1716 "model name changed without changing its wire shape",
1717 ));
1718 }
1719
1720 for old in &previous.fields {
1721 let field_path = format!("{path}.fields.{}", old.name);
1722 let Some(new) = current.fields.iter().find(|field| field.name == old.name) else {
1723 changes.push(change(
1724 CompatibilityImpact::Breaking,
1725 field_path,
1726 "model_field_removed",
1727 "a model field was removed",
1728 ));
1729 continue;
1730 };
1731
1732 if old.required != new.required {
1733 let impact = match (direction, old.required, new.required) {
1734 (TypeDirection::Input, false, true) | (TypeDirection::Output, true, false) => {
1735 CompatibilityImpact::Breaking
1736 }
1737 _ => CompatibilityImpact::NonBreaking,
1738 };
1739 changes.push(change(
1740 impact,
1741 format!("{field_path}.required"),
1742 "model_field_requiredness_changed",
1743 "model field requiredness changed",
1744 ));
1745 }
1746 compare_type(
1747 &old.ty,
1748 &new.ty,
1749 &format!("{field_path}.type"),
1750 direction,
1751 changes,
1752 );
1753 compare_validation(
1754 &old.validation,
1755 &new.validation,
1756 &field_path,
1757 direction,
1758 changes,
1759 );
1760 }
1761
1762 for new in ¤t.fields {
1763 if previous.fields.iter().any(|field| field.name == new.name) {
1764 continue;
1765 }
1766 let impact = match direction {
1767 TypeDirection::Input if new.required => CompatibilityImpact::Breaking,
1768 TypeDirection::Input | TypeDirection::Output => CompatibilityImpact::NonBreaking,
1769 };
1770 changes.push(change(
1771 impact,
1772 format!("{path}.fields.{}", new.name),
1773 if new.required {
1774 "required_model_field_added"
1775 } else {
1776 "optional_model_field_added"
1777 },
1778 "a model field was added",
1779 ));
1780 }
1781}
1782
1783fn compare_validation(
1784 previous: &[ValidationRule],
1785 current: &[ValidationRule],
1786 path: &str,
1787 direction: TypeDirection,
1788 changes: &mut Vec<CompatibilityChange>,
1789) {
1790 if matches!(direction, TypeDirection::Output) {
1791 if previous != current {
1792 changes.push(change(
1793 CompatibilityImpact::Metadata,
1794 format!("{path}.validation"),
1795 "output_validation_changed",
1796 "output model validation metadata changed",
1797 ));
1798 }
1799 return;
1800 }
1801
1802 compare_min_length(previous, current, path, changes);
1803 compare_max_length(previous, current, path, changes);
1804 let old_email = previous
1805 .iter()
1806 .any(|rule| matches!(rule, ValidationRule::Email));
1807 let new_email = current
1808 .iter()
1809 .any(|rule| matches!(rule, ValidationRule::Email));
1810 if old_email != new_email {
1811 changes.push(change(
1812 if new_email {
1813 CompatibilityImpact::Breaking
1814 } else {
1815 CompatibilityImpact::NonBreaking
1816 },
1817 format!("{path}.validation.email"),
1818 "email_validation_changed",
1819 "email validation changed",
1820 ));
1821 }
1822 compare_validation_string_rules(
1823 previous,
1824 current,
1825 path,
1826 "alias",
1827 |rule| match rule {
1828 ValidationRule::Alias(value) => Some(value),
1829 ValidationRule::MinLength(_)
1830 | ValidationRule::MaxLength(_)
1831 | ValidationRule::Email
1832 | ValidationRule::Custom(_)
1833 | ValidationRule::Nested => None,
1834 },
1835 CompatibilityImpact::NonBreaking,
1836 CompatibilityImpact::Breaking,
1837 changes,
1838 );
1839 compare_validation_string_rules(
1840 previous,
1841 current,
1842 path,
1843 "custom_validator",
1844 |rule| match rule {
1845 ValidationRule::Custom(value) => Some(value),
1846 ValidationRule::MinLength(_)
1847 | ValidationRule::MaxLength(_)
1848 | ValidationRule::Email
1849 | ValidationRule::Alias(_)
1850 | ValidationRule::Nested => None,
1851 },
1852 CompatibilityImpact::Breaking,
1853 CompatibilityImpact::NonBreaking,
1854 changes,
1855 );
1856 let old_nested = previous
1857 .iter()
1858 .any(|rule| matches!(rule, ValidationRule::Nested));
1859 let new_nested = current
1860 .iter()
1861 .any(|rule| matches!(rule, ValidationRule::Nested));
1862 if old_nested != new_nested {
1863 changes.push(change(
1864 if new_nested {
1865 CompatibilityImpact::Breaking
1866 } else {
1867 CompatibilityImpact::NonBreaking
1868 },
1869 format!("{path}.validation.nested"),
1870 "nested_validation_changed",
1871 "nested model validation changed",
1872 ));
1873 }
1874}
1875
1876#[allow(clippy::too_many_arguments)]
1877fn compare_validation_string_rules<'rule>(
1878 previous: &'rule [ValidationRule],
1879 current: &'rule [ValidationRule],
1880 path: &str,
1881 code_prefix: &str,
1882 value: impl Fn(&'rule ValidationRule) -> Option<&'rule String>,
1883 added_impact: CompatibilityImpact,
1884 removed_impact: CompatibilityImpact,
1885 changes: &mut Vec<CompatibilityChange>,
1886) {
1887 let previous = previous
1888 .iter()
1889 .filter_map(&value)
1890 .cloned()
1891 .collect::<Vec<_>>();
1892 let current = current
1893 .iter()
1894 .filter_map(value)
1895 .cloned()
1896 .collect::<Vec<_>>();
1897 compare_string_set(
1898 &previous,
1899 ¤t,
1900 &format!("{path}.validation.{code_prefix}"),
1901 code_prefix,
1902 added_impact,
1903 removed_impact,
1904 changes,
1905 );
1906}
1907
1908fn compare_min_length(
1909 previous: &[ValidationRule],
1910 current: &[ValidationRule],
1911 path: &str,
1912 changes: &mut Vec<CompatibilityChange>,
1913) {
1914 let old = previous.iter().filter_map(|rule| match rule {
1915 ValidationRule::MinLength(value) => Some(*value),
1916 ValidationRule::MaxLength(_)
1917 | ValidationRule::Email
1918 | ValidationRule::Alias(_)
1919 | ValidationRule::Custom(_)
1920 | ValidationRule::Nested => None,
1921 });
1922 let new = current.iter().filter_map(|rule| match rule {
1923 ValidationRule::MinLength(value) => Some(*value),
1924 ValidationRule::MaxLength(_)
1925 | ValidationRule::Email
1926 | ValidationRule::Alias(_)
1927 | ValidationRule::Custom(_)
1928 | ValidationRule::Nested => None,
1929 });
1930 let old = old.max();
1931 let new = new.max();
1932 if old != new {
1933 changes.push(change(
1934 if new.unwrap_or(0) > old.unwrap_or(0) {
1935 CompatibilityImpact::Breaking
1936 } else {
1937 CompatibilityImpact::NonBreaking
1938 },
1939 format!("{path}.validation.min_length"),
1940 "minimum_length_changed",
1941 "minimum accepted length changed",
1942 ));
1943 }
1944}
1945
1946fn compare_max_length(
1947 previous: &[ValidationRule],
1948 current: &[ValidationRule],
1949 path: &str,
1950 changes: &mut Vec<CompatibilityChange>,
1951) {
1952 let old = previous.iter().filter_map(|rule| match rule {
1953 ValidationRule::MaxLength(value) => Some(*value),
1954 ValidationRule::MinLength(_)
1955 | ValidationRule::Email
1956 | ValidationRule::Alias(_)
1957 | ValidationRule::Custom(_)
1958 | ValidationRule::Nested => None,
1959 });
1960 let new = current.iter().filter_map(|rule| match rule {
1961 ValidationRule::MaxLength(value) => Some(*value),
1962 ValidationRule::MinLength(_)
1963 | ValidationRule::Email
1964 | ValidationRule::Alias(_)
1965 | ValidationRule::Custom(_)
1966 | ValidationRule::Nested => None,
1967 });
1968 let old = old.min();
1969 let new = new.min();
1970 if old != new {
1971 let stricter = match (old, new) {
1972 (None, Some(_)) => true,
1973 (Some(old), Some(new)) => new < old,
1974 (Some(_) | None, None) => false,
1975 };
1976 changes.push(change(
1977 if stricter {
1978 CompatibilityImpact::Breaking
1979 } else {
1980 CompatibilityImpact::NonBreaking
1981 },
1982 format!("{path}.validation.max_length"),
1983 "maximum_length_changed",
1984 "maximum accepted length changed",
1985 ));
1986 }
1987}
1988
1989fn compare_string_set(
1990 previous: &[String],
1991 current: &[String],
1992 path: &str,
1993 code_prefix: &str,
1994 added_impact: CompatibilityImpact,
1995 removed_impact: CompatibilityImpact,
1996 changes: &mut Vec<CompatibilityChange>,
1997) {
1998 for old in previous {
1999 if !current.iter().any(|new| new == old) {
2000 changes.push(change(
2001 removed_impact,
2002 format!("{path}.{old}"),
2003 format!("{code_prefix}_removed"),
2004 "a declared value was removed",
2005 ));
2006 }
2007 }
2008 for new in current {
2009 if !previous.iter().any(|old| old == new) {
2010 changes.push(change(
2011 added_impact,
2012 format!("{path}.{new}"),
2013 format!("{code_prefix}_added"),
2014 "a declared value was added",
2015 ));
2016 }
2017 }
2018}
2019
2020fn change(
2021 impact: CompatibilityImpact,
2022 path: impl Into<String>,
2023 code: impl Into<String>,
2024 message: impl Into<String>,
2025) -> CompatibilityChange {
2026 CompatibilityChange::new(impact, path, code, message)
2027}
2028
2029fn same_input_key(left: &InputDescriptor, right: &InputDescriptor) -> bool {
2030 left.source == right.source
2031 && if left.source == InputSource::Header {
2032 left.name.eq_ignore_ascii_case(&right.name)
2033 } else {
2034 left.name == right.name
2035 }
2036}
2037
2038fn input_path(input: &InputDescriptor) -> String {
2039 let name = if input.source == InputSource::Header {
2040 input.name.to_ascii_lowercase()
2041 } else {
2042 input.name.clone()
2043 };
2044 format!("inputs.{}.{}", input_source_name(input.source), name)
2045}
2046
2047fn same_response_key(left: &ResponseDescriptor, right: &ResponseDescriptor) -> bool {
2048 left.status == right.status && left.error_code == right.error_code
2049}
2050
2051fn response_path(response: &ResponseDescriptor) -> String {
2052 match &response.error_code {
2053 Some(code) => format!("responses.{}.{}", response.status, code),
2054 None => format!("responses.{}.success", response.status),
2055 }
2056}
2057
2058const fn input_source_tag(source: InputSource) -> u8 {
2059 match source {
2060 InputSource::Path => 0,
2061 InputSource::Query => 1,
2062 InputSource::Header => 2,
2063 InputSource::Cookie => 3,
2064 InputSource::Json => 4,
2065 InputSource::Form => 5,
2066 InputSource::Multipart => 6,
2067 InputSource::File => 7,
2068 InputSource::Stream => 8,
2069 }
2070}
2071
2072const fn input_source_name(source: InputSource) -> &'static str {
2073 match source {
2074 InputSource::Path => "path",
2075 InputSource::Query => "query",
2076 InputSource::Header => "header",
2077 InputSource::Cookie => "cookie",
2078 InputSource::Json => "json",
2079 InputSource::Form => "form",
2080 InputSource::Multipart => "multipart",
2081 InputSource::File => "file",
2082 InputSource::Stream => "stream",
2083 }
2084}
2085
2086const fn risk_tag(risk: OperationRisk) -> u8 {
2087 match risk {
2088 OperationRisk::Read => 0,
2089 OperationRisk::Write => 1,
2090 OperationRisk::Destructive => 2,
2091 }
2092}
2093
2094const fn confirmation_tag(confirmation: Confirmation) -> u8 {
2095 match confirmation {
2096 Confirmation::Never => 0,
2097 Confirmation::Required => 1,
2098 }
2099}
2100
2101const fn exposure_tag(exposure: OutputExposure) -> u8 {
2102 match exposure {
2103 OutputExposure::Full => 0,
2104 OutputExposure::SummaryOnly => 1,
2105 OutputExposure::None => 2,
2106 }
2107}
2108
2109const fn impact_tag(impact: CompatibilityImpact) -> u8 {
2110 match impact {
2111 CompatibilityImpact::Breaking => 0,
2112 CompatibilityImpact::NonBreaking => 1,
2113 CompatibilityImpact::Metadata => 2,
2114 }
2115}
2116
2117impl From<&str> for TypeDescriptor {
2118 fn from(value: &str) -> Self {
2119 Self::new(value.to_string())
2120 }
2121}
2122
2123#[cfg(test)]
2124mod tests {
2125 use alloc::string::{String, ToString};
2126 use alloc::vec;
2127 use alloc::vec::Vec;
2128
2129 use super::{
2130 AgentPolicy, ApiSchema, Compatibility, Confirmation, DependencyDescriptor, FieldDescriptor,
2131 InputDescriptor, InputSource, McpToolDescriptor, ModelDescriptor, OperationContract,
2132 OperationId, OperationRisk, ResponseDescriptor, ResponseHeader, SchemaKind,
2133 SecurityRequirement, TypeDescriptor, ValidationRule,
2134 };
2135
2136 #[test]
2137 fn operation_ids_are_stable_and_conservative() {
2138 assert!(OperationId::new("users.create-v2").is_ok());
2139 assert!(OperationId::new("").is_err());
2140 assert!(OperationId::new("users/create").is_err());
2141 assert!(OperationId::new("users create").is_err());
2142 }
2143
2144 #[test]
2145 fn canonical_contract_is_independent_of_declaration_order() {
2146 let original = sample_contract();
2147 let mut reordered = original.clone();
2148 reordered.inputs.reverse();
2149 reordered.dependencies.reverse();
2150 reordered.security.reverse();
2151 reordered.security[0].scopes.reverse();
2152 reordered.responses.reverse();
2153 reordered
2154 .responses
2155 .iter_mut()
2156 .find(|response| response.status == 201)
2157 .unwrap()
2158 .headers
2159 .reverse();
2160 let model = reordered.inputs[1].ty.model.as_mut().unwrap();
2161 model.fields.reverse();
2162 model.fields[1].validation.reverse();
2163
2164 assert_eq!(original.canonical_bytes(), reordered.canonical_bytes());
2165 assert_eq!(original.fingerprint(), reordered.fingerprint());
2166 }
2167
2168 #[test]
2169 fn deprecated_input_mirror_does_not_change_fingerprint() {
2170 let original = sample_contract();
2171 let mut changed = original.clone();
2172 changed.input = Some(TypeDescriptor::scalar("bool", SchemaKind::Boolean));
2173
2174 assert_eq!(original.fingerprint(), changed.fingerprint());
2175 assert!(
2176 Compatibility::compare(&original, &changed)
2177 .changes
2178 .is_empty()
2179 );
2180 }
2181
2182 #[test]
2183 fn canonical_fingerprint_has_a_golden_value() {
2184 let fingerprint = sample_contract().fingerprint().to_string();
2185 assert_eq!(
2186 fingerprint,
2187 "blazingly-contract-v1.3-sha256:\
2188 a4164ce1818ddd0509dfdd92d6ef9e72f146d95df073844177ecded466a04381"
2189 );
2190 }
2191
2192 #[test]
2193 fn compatibility_classifies_input_and_response_evolution() {
2194 let original = sample_contract();
2195
2196 let mut optional_input = original.clone();
2197 optional_input.inputs.push(InputDescriptor::new(
2198 "x-request-id",
2199 InputSource::Header,
2200 false,
2201 String::type_descriptor(),
2202 ));
2203 assert!(Compatibility::compare(&original, &optional_input).is_backward_compatible());
2204
2205 let mut required_input = original.clone();
2206 required_input.inputs.push(InputDescriptor::new(
2207 "tenant",
2208 InputSource::Path,
2209 true,
2210 String::type_descriptor(),
2211 ));
2212 let report = Compatibility::compare(&original, &required_input);
2213 assert!(!report.is_backward_compatible());
2214 assert!(
2215 report
2216 .breaking_changes()
2217 .any(|change| change.code == "required_input_added")
2218 );
2219
2220 let mut removed_response = original.clone();
2221 removed_response.responses.pop();
2222 let report = Compatibility::compare(&original, &removed_response);
2223 assert!(
2224 report
2225 .breaking_changes()
2226 .any(|change| change.code == "response_removed")
2227 );
2228 }
2229
2230 #[test]
2231 fn compatibility_catches_stricter_model_agent_and_mcp_policies() {
2232 let original = sample_contract();
2233 let mut stricter = original.clone();
2234 let model = stricter.inputs[0].ty.model.as_mut().unwrap();
2235 model.fields[0]
2236 .validation
2237 .push(ValidationRule::MinLength(12));
2238 stricter.agent.risk = OperationRisk::Destructive;
2239 stricter.agent.confirmation = Confirmation::Required;
2240 stricter.agent.idempotent = false;
2241 stricter.mcp.as_mut().unwrap().expose_output = super::OutputExposure::None;
2242
2243 let report = Compatibility::compare(&original, &stricter);
2244 assert!(!report.is_backward_compatible());
2245 assert!(
2246 report
2247 .breaking_changes()
2248 .any(|change| change.code == "minimum_length_changed")
2249 );
2250 assert!(
2251 report
2252 .breaking_changes()
2253 .any(|change| change.code == "agent_risk_changed")
2254 );
2255 assert!(
2256 report
2257 .breaking_changes()
2258 .any(|change| change.code == "mcp_output_exposure_changed")
2259 );
2260 }
2261
2262 #[test]
2263 fn compatibility_classifies_alias_custom_and_nested_validation() {
2264 let original = sample_contract();
2265
2266 let mut alias_added = original.clone();
2267 alias_added.inputs[0].ty.model.as_mut().unwrap().fields[0]
2268 .validation
2269 .push(ValidationRule::Alias("legacy_name".to_string()));
2270 assert!(Compatibility::compare(&original, &alias_added).is_backward_compatible());
2271 assert!(!Compatibility::compare(&alias_added, &original).is_backward_compatible());
2272
2273 let mut custom_added = original.clone();
2274 custom_added.inputs[0].ty.model.as_mut().unwrap().fields[0]
2275 .validation
2276 .push(ValidationRule::Custom("validate_name".to_string()));
2277 let custom_report = Compatibility::compare(&original, &custom_added);
2278 assert!(
2279 custom_report
2280 .breaking_changes()
2281 .any(|change| change.code == "custom_validator_added")
2282 );
2283
2284 let mut nested_added = original.clone();
2285 nested_added.inputs[0].ty.model.as_mut().unwrap().fields[0]
2286 .validation
2287 .push(ValidationRule::Nested);
2288 let nested_report = Compatibility::compare(&original, &nested_added);
2289 assert!(
2290 nested_report
2291 .breaking_changes()
2292 .any(|change| change.code == "nested_validation_changed")
2293 );
2294 }
2295
2296 #[test]
2297 fn collection_descriptors_retain_nested_model_contracts() {
2298 let descriptor = Vec::<TestModel>::type_descriptor();
2299
2300 assert!(matches!(descriptor.schema, SchemaKind::Array(_)));
2301 assert_eq!(
2302 descriptor
2303 .items
2304 .as_ref()
2305 .and_then(|item| item.model.as_ref())
2306 .map(|model| model.name.as_str()),
2307 Some("TestModel")
2308 );
2309 }
2310
2311 #[test]
2312 fn a_value_types_rules_ride_on_its_type_descriptor() {
2313 let tag = TypeDescriptor::scalar("Tag", SchemaKind::String).with_constraints(vec![
2314 ValidationRule::MinLength(1),
2315 ValidationRule::MaxLength(20),
2316 ]);
2317 let tags = TypeDescriptor {
2318 rust_name: "Vec<Tag>".to_string(),
2319 schema: SchemaKind::Array(alloc::boxed::Box::new(SchemaKind::String)),
2320 model: None,
2321 items: Some(alloc::boxed::Box::new(tag)),
2322 constraints: Vec::new(),
2323 };
2324
2325 assert_eq!(
2326 tags.items.as_ref().map(|item| item.constraints.as_slice()),
2327 Some([ValidationRule::MinLength(1), ValidationRule::MaxLength(20)].as_slice()),
2328 "a collection item keeps the rules its type declared"
2329 );
2330 }
2331
2332 #[test]
2333 fn tightening_a_collection_items_own_rules_is_breaking() {
2334 let original = contract_with_tag_bounds(20);
2335 let tightened = contract_with_tag_bounds(5);
2336
2337 assert_ne!(
2338 original.fingerprint(),
2339 tightened.fingerprint(),
2340 "an item's own bounds are part of the contract identity"
2341 );
2342 let report = Compatibility::compare(&original, &tightened);
2343 assert!(!report.is_backward_compatible());
2344 assert!(
2345 report
2346 .breaking_changes()
2347 .any(|change| change.code == "maximum_length_changed"
2348 && change.path.contains("items")),
2349 "the finding names the item, not the collection: {:?}",
2350 report.changes
2351 );
2352 }
2353
2354 fn contract_with_tag_bounds(maximum: usize) -> OperationContract {
2355 let tag = TypeDescriptor::scalar("Tag", SchemaKind::String)
2356 .with_constraints(vec![ValidationRule::MaxLength(maximum)]);
2357 let tags = TypeDescriptor {
2358 rust_name: "Vec<Tag>".to_string(),
2359 schema: SchemaKind::Array(alloc::boxed::Box::new(SchemaKind::String)),
2360 model: None,
2361 items: Some(alloc::boxed::Box::new(tag)),
2362 constraints: Vec::new(),
2363 };
2364 OperationContract::new(
2365 "notes.create",
2366 "Create one note",
2367 None,
2368 vec![ResponseDescriptor::success(201, None)],
2369 )
2370 .unwrap()
2371 .with_inputs(vec![InputDescriptor::new(
2372 "body",
2373 InputSource::Json,
2374 true,
2375 TypeDescriptor::model(ModelDescriptor::new(
2376 "CreateNote",
2377 vec![FieldDescriptor::new("tags", true, tags, vec![])],
2378 )),
2379 )])
2380 }
2381
2382 struct TestModel;
2383
2384 impl super::ApiModel for TestModel {
2385 fn model_descriptor() -> ModelDescriptor {
2386 request_model()
2387 }
2388
2389 fn validate(&self) -> Result<(), super::ValidationErrors> {
2390 Ok(())
2391 }
2392 }
2393
2394 fn sample_contract() -> OperationContract {
2395 OperationContract::new(
2396 "users.create",
2397 "Create one user",
2398 None,
2399 vec![
2400 ResponseDescriptor::success(
2401 201,
2402 Some(TypeDescriptor::model(ModelDescriptor::new(
2403 "CreatedUser",
2404 vec![
2405 FieldDescriptor::new("id", true, String::type_descriptor(), vec![]),
2406 FieldDescriptor::new(
2407 "display_name",
2408 true,
2409 String::type_descriptor(),
2410 vec![],
2411 ),
2412 ],
2413 ))),
2414 )
2415 .with_headers(vec![
2416 ResponseHeader::new("Location", "/users/{id}"),
2417 ResponseHeader::new("X-Request-Id", "generated"),
2418 ]),
2419 ResponseDescriptor::error(409, "user_exists", "user already exists", None),
2420 ],
2421 )
2422 .unwrap()
2423 .with_inputs(vec![
2424 InputDescriptor::new(
2425 "body",
2426 InputSource::Json,
2427 true,
2428 TypeDescriptor::model(request_model()),
2429 ),
2430 InputDescriptor::new(
2431 "x-tenant",
2432 InputSource::Header,
2433 true,
2434 String::type_descriptor(),
2435 ),
2436 ])
2437 .with_dependencies(vec![
2438 DependencyDescriptor::new("UserRepository"),
2439 DependencyDescriptor::new("AuditLog"),
2440 ])
2441 .with_security(vec![
2442 SecurityRequirement::new("bearer")
2443 .with_scopes(vec!["users:write".to_string(), "profile:write".to_string()]),
2444 SecurityRequirement::new("tenant"),
2445 ])
2446 .with_agent_policy(AgentPolicy {
2447 risk: OperationRisk::Write,
2448 confirmation: Confirmation::Never,
2449 idempotent: true,
2450 })
2451 .with_mcp_tool(McpToolDescriptor::new(
2452 "users_create",
2453 "Create one user account",
2454 ))
2455 }
2456
2457 fn request_model() -> ModelDescriptor {
2458 ModelDescriptor::new(
2459 "TestModel",
2460 vec![
2461 FieldDescriptor::new(
2462 "email",
2463 true,
2464 String::type_descriptor(),
2465 vec![ValidationRule::Email, ValidationRule::MinLength(3)],
2466 ),
2467 FieldDescriptor::new(
2468 "display_name",
2469 false,
2470 String::type_descriptor(),
2471 vec![ValidationRule::MaxLength(80)],
2472 ),
2473 ],
2474 )
2475 }
2476}