1use std::error::Error;
4use std::fmt;
5
6use crate::{BoxId, CapabilityId, CapabilityName, ContractRevision, Deprecation, TypeDescriptor};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
10pub enum CapabilityShape {
11 Unary,
13 ServerStreaming,
15 ClientStreaming,
17 BidirectionalStreaming,
19 EventSubscription,
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
25pub enum ExposureLevel {
26 CodeOnly,
28 Internal,
30 External,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
36pub enum Idempotency {
37 None,
39 Inherent,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct CapabilityDescriptor {
46 id: CapabilityId,
47 input: TypeDescriptor,
48 output: TypeDescriptor,
49 error: TypeDescriptor,
50 shape: CapabilityShape,
51 max_exposure: ExposureLevel,
52 idempotency: Idempotency,
53 deprecation: Option<Deprecation>,
54}
55
56impl CapabilityDescriptor {
57 #[allow(clippy::too_many_arguments)]
59 pub fn new(
60 id: CapabilityId,
61 input: TypeDescriptor,
62 output: TypeDescriptor,
63 error: TypeDescriptor,
64 shape: CapabilityShape,
65 max_exposure: ExposureLevel,
66 idempotency: Idempotency,
67 deprecation: Option<Deprecation>,
68 ) -> Self {
69 Self {
70 id,
71 input,
72 output,
73 error,
74 shape,
75 max_exposure,
76 idempotency,
77 deprecation,
78 }
79 }
80
81 pub fn name(&self) -> &CapabilityName {
83 self.id.name()
84 }
85
86 pub fn id(&self) -> &CapabilityId {
88 &self.id
89 }
90
91 pub fn input(&self) -> &TypeDescriptor {
93 &self.input
94 }
95
96 pub fn output(&self) -> &TypeDescriptor {
98 &self.output
99 }
100
101 pub fn error(&self) -> &TypeDescriptor {
103 &self.error
104 }
105
106 pub fn shape(&self) -> CapabilityShape {
108 self.shape
109 }
110
111 pub fn max_exposure(&self) -> ExposureLevel {
113 self.max_exposure
114 }
115
116 pub fn idempotency(&self) -> Idempotency {
118 self.idempotency
119 }
120
121 pub fn deprecation(&self) -> Option<&Deprecation> {
123 self.deprecation.as_ref()
124 }
125}
126
127#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct ContractDescriptor {
130 box_id: BoxId,
131 capabilities: Vec<CapabilityDescriptor>,
132 revision: ContractRevision,
133}
134
135impl ContractDescriptor {
136 pub fn new(
138 box_id: BoxId,
139 capabilities: impl IntoIterator<Item = CapabilityDescriptor>,
140 revision: ContractRevision,
141 ) -> Result<Self, ContractDescriptorError> {
142 let mut accepted: Vec<CapabilityDescriptor> = Vec::new();
143 for capability in capabilities {
144 if capability.id().box_id() != &box_id {
145 return Err(ContractDescriptorError::CapabilityBoxMismatch {
146 contract_box: box_id,
147 capability: capability.id,
148 });
149 }
150 if accepted.iter().any(|known| known.id() == capability.id()) {
151 return Err(ContractDescriptorError::DuplicateCapability {
152 capability: capability.id,
153 });
154 }
155 accepted.push(capability);
156 }
157 Ok(Self {
158 box_id,
159 capabilities: accepted,
160 revision,
161 })
162 }
163
164 pub fn box_id(&self) -> &BoxId {
166 &self.box_id
167 }
168
169 pub fn capabilities(&self) -> &[CapabilityDescriptor] {
171 &self.capabilities
172 }
173
174 pub fn revision(&self) -> &ContractRevision {
176 &self.revision
177 }
178}
179
180#[derive(Debug, Clone, PartialEq, Eq)]
182#[non_exhaustive]
183pub enum ContractDescriptorError {
184 CapabilityBoxMismatch {
186 contract_box: BoxId,
188 capability: CapabilityId,
190 },
191 DuplicateCapability {
193 capability: CapabilityId,
195 },
196}
197
198impl fmt::Display for ContractDescriptorError {
199 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
200 match self {
201 Self::CapabilityBoxMismatch {
202 contract_box,
203 capability,
204 } => write!(
205 formatter,
206 "capability {capability} does not belong to contract box {contract_box}"
207 ),
208 Self::DuplicateCapability { capability } => {
209 write!(formatter, "duplicate capability: {capability}")
210 }
211 }
212 }
213}
214
215impl Error for ContractDescriptorError {}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220 use crate::DescriptorRef;
221
222 fn id(box_name: &str, capability: &str) -> CapabilityId {
223 CapabilityId::new(
224 BoxId::new(box_name).unwrap(),
225 CapabilityName::new(capability).unwrap(),
226 )
227 }
228
229 fn capability(
230 id: CapabilityId,
231 shape: CapabilityShape,
232 idempotency: Idempotency,
233 ) -> CapabilityDescriptor {
234 CapabilityDescriptor::new(
235 id,
236 TypeDescriptor::string(),
237 TypeDescriptor::u64(),
238 TypeDescriptor::bool(),
239 shape,
240 ExposureLevel::Internal,
241 idempotency,
242 (idempotency == Idempotency::None)
243 .then(|| Deprecation::new(Some("use replacement".into()))),
244 )
245 }
246
247 #[test]
248 fn complete_outward_view_preserves_capability_order() {
249 let box_id = BoxId::new("billing").unwrap();
250 let first = capability(
251 id("billing", "quote"),
252 CapabilityShape::Unary,
253 Idempotency::None,
254 );
255 let second = capability(
256 id("billing", "invoice"),
257 CapabilityShape::ServerStreaming,
258 Idempotency::Inherent,
259 );
260 let revision = ContractRevision::new("sha256:123").unwrap();
261 let contract = ContractDescriptor::new(
262 box_id.clone(),
263 [first.clone(), second.clone()],
264 revision.clone(),
265 )
266 .unwrap();
267
268 assert_eq!(contract.box_id(), &box_id);
269 assert_eq!(contract.revision(), &revision);
270 assert_eq!(contract.capabilities(), &[first, second]);
271 let viewed = &contract.capabilities()[0];
272 assert_eq!(viewed.name().as_str(), "quote");
273 assert_eq!(viewed.id(), &id("billing", "quote"));
274 assert_eq!(viewed.input().view(), DescriptorRef::String);
275 assert_eq!(viewed.output().view(), DescriptorRef::U64);
276 assert_eq!(viewed.error().view(), DescriptorRef::Bool);
277 assert_eq!(viewed.shape(), CapabilityShape::Unary);
278 assert_eq!(viewed.max_exposure(), ExposureLevel::Internal);
279 assert_eq!(viewed.idempotency(), Idempotency::None);
280 assert_eq!(
281 viewed.deprecation().unwrap().note(),
282 Some("use replacement")
283 );
284 assert_eq!(contract.capabilities()[1].deprecation(), None);
285 }
286
287 #[test]
288 fn outward_contract_construction_requires_no_implementation_data() {
289 let contract = ContractDescriptor::new(
290 BoxId::new("empty").unwrap(),
291 [],
292 ContractRevision::new("r1").unwrap(),
293 )
294 .unwrap();
295 assert!(contract.capabilities().is_empty());
296 }
297
298 #[test]
299 fn every_interaction_shape_and_idempotency_variant_is_constructible() {
300 let shapes = [
301 CapabilityShape::Unary,
302 CapabilityShape::ServerStreaming,
303 CapabilityShape::ClientStreaming,
304 CapabilityShape::BidirectionalStreaming,
305 CapabilityShape::EventSubscription,
306 ];
307 for (index, shape) in shapes.into_iter().enumerate() {
308 let idempotency = if index % 2 == 0 {
309 Idempotency::None
310 } else {
311 Idempotency::Inherent
312 };
313 let descriptor = capability(id("box", &format!("cap_{index}")), shape, idempotency);
314 assert_eq!(descriptor.shape(), shape);
315 assert_eq!(descriptor.idempotency(), idempotency);
316 }
317 }
318
319 #[test]
320 fn exposure_levels_form_the_declared_lattice() {
321 assert!(ExposureLevel::CodeOnly < ExposureLevel::Internal);
322 assert!(ExposureLevel::Internal < ExposureLevel::External);
323 }
324
325 #[test]
326 fn contract_rejects_identity_mismatch_exactly() {
327 let error = ContractDescriptor::new(
328 BoxId::new("expected").unwrap(),
329 [capability(
330 id("other", "run"),
331 CapabilityShape::Unary,
332 Idempotency::None,
333 )],
334 ContractRevision::new("r1").unwrap(),
335 )
336 .unwrap_err();
337 assert_eq!(
338 error,
339 ContractDescriptorError::CapabilityBoxMismatch {
340 contract_box: BoxId::new("expected").unwrap(),
341 capability: id("other", "run"),
342 }
343 );
344 assert_eq!(
345 error.to_string(),
346 "capability other.run does not belong to contract box expected"
347 );
348 }
349
350 #[test]
351 fn contract_rejects_duplicate_capabilities_exactly() {
352 let duplicate = capability(id("box", "run"), CapabilityShape::Unary, Idempotency::None);
353 let error = ContractDescriptor::new(
354 BoxId::new("box").unwrap(),
355 [duplicate.clone(), duplicate],
356 ContractRevision::new("r1").unwrap(),
357 )
358 .unwrap_err();
359 assert_eq!(
360 error,
361 ContractDescriptorError::DuplicateCapability {
362 capability: id("box", "run"),
363 }
364 );
365 assert_eq!(error.to_string(), "duplicate capability: box.run");
366 }
367
368 #[test]
369 fn descriptors_are_structurally_equal_owned_plain_data() {
370 fn assert_bounds<T: Send + Sync + 'static>() {}
371
372 let build = || {
373 ContractDescriptor::new(
374 BoxId::new("box").unwrap(),
375 [capability(
376 id("box", "run"),
377 CapabilityShape::Unary,
378 Idempotency::None,
379 )],
380 ContractRevision::new("r1").unwrap(),
381 )
382 .unwrap()
383 };
384 assert_eq!(build(), build());
385 assert_bounds::<CapabilityShape>();
386 assert_bounds::<ExposureLevel>();
387 assert_bounds::<Idempotency>();
388 assert_bounds::<CapabilityDescriptor>();
389 assert_bounds::<ContractDescriptor>();
390 assert_bounds::<ContractDescriptorError>();
391 }
392}