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