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