Skip to main content

boxology_runtime/
assembly.rs

1//! Deterministic, payload-free composition-assembly diagnostics.
2
3use std::error::Error;
4use std::fmt;
5
6use boxology_contract::{BoxId, CapabilityId, Detail, ExposureLevel};
7
8/// One deterministic composition-assembly validation failure.
9#[non_exhaustive]
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum AssemblyError {
12    /// A box identity was registered more than once.
13    DuplicateBox {
14        /// The repeated box identity.
15        box_id: BoxId,
16    },
17    /// An import resolution named an unregistered consumer.
18    UnknownImportConsumer {
19        /// The unregistered consumer identity.
20        consumer: BoxId,
21    },
22    /// An import resolution named an undeclared slot.
23    UnknownImportSlot {
24        /// The registered consumer identity.
25        consumer: BoxId,
26        /// The undeclared import-slot identity.
27        slot: BoxId,
28    },
29    /// An import slot was resolved more than once.
30    DuplicateImportResolution {
31        /// The registered consumer identity.
32        consumer: BoxId,
33        /// The multiply resolved import-slot identity.
34        slot: BoxId,
35    },
36    /// An import resolution named an unregistered target.
37    UnknownImportTarget {
38        /// The registered consumer identity.
39        consumer: BoxId,
40        /// The declared import-slot identity.
41        slot: BoxId,
42        /// The unregistered target identity.
43        target: BoxId,
44    },
45    /// A declared import slot had no resolution.
46    MissingImportResolution {
47        /// The registered consumer identity.
48        consumer: BoxId,
49        /// The unresolved import-slot identity.
50        slot: BoxId,
51    },
52    /// A resolved target lacked one declared imported capability.
53    MissingImportedCapability {
54        /// The registered consumer identity.
55        consumer: BoxId,
56        /// The declared import-slot identity.
57        slot: BoxId,
58        /// The capability absent from the resolved target contract.
59        capability: CapabilityId,
60    },
61    /// An exposure named an unregistered provider.
62    UnknownExposureProvider {
63        /// The unregistered provider identity.
64        provider: BoxId,
65    },
66    /// An exposure named a capability absent from its provider contract.
67    UnknownExposedCapability {
68        /// The registered provider identity.
69        provider: BoxId,
70        /// The absent capability identity.
71        capability: CapabilityId,
72    },
73    /// An exposure requested a boundary wider than the capability permits.
74    ExposureExceedsMaximum {
75        /// The capability being exposed.
76        capability: CapabilityId,
77        /// The requested exposure boundary.
78        requested: ExposureLevel,
79        /// The greatest permitted exposure boundary.
80        maximum: ExposureLevel,
81    },
82    /// A transport rejected one otherwise valid exposure.
83    TransportConformanceFailed {
84        /// The rejected capability.
85        capability: CapabilityId,
86        /// Producer-owned payload-safe diagnostic detail.
87        detail: Detail,
88    },
89    /// A transport failed transactional descriptor preflight.
90    TransportPrepareFailed {
91        /// Producer-owned payload-safe diagnostic detail.
92        detail: Detail,
93    },
94    /// A transport failed transactional startup.
95    TransportStartFailed {
96        /// Producer-owned payload-safe diagnostic detail.
97        detail: Detail,
98    },
99}
100
101impl fmt::Display for AssemblyError {
102    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
103        match self {
104            Self::DuplicateBox { box_id } => {
105                write!(formatter, "duplicate box registration: {box_id}")
106            }
107            Self::UnknownImportConsumer { consumer } => {
108                write!(formatter, "unknown import consumer: {consumer}")
109            }
110            Self::UnknownImportSlot { consumer, slot } => {
111                write!(
112                    formatter,
113                    "unknown import slot {slot} for consumer {consumer}"
114                )
115            }
116            Self::DuplicateImportResolution { consumer, slot } => write!(
117                formatter,
118                "duplicate import resolution for consumer {consumer}, slot {slot}"
119            ),
120            Self::UnknownImportTarget {
121                consumer,
122                slot,
123                target,
124            } => write!(
125                formatter,
126                "unknown import target {target} for consumer {consumer}, slot {slot}"
127            ),
128            Self::MissingImportResolution { consumer, slot } => write!(
129                formatter,
130                "missing import resolution for consumer {consumer}, slot {slot}"
131            ),
132            Self::MissingImportedCapability {
133                consumer,
134                slot,
135                capability,
136            } => write!(
137                formatter,
138                "import target for consumer {consumer}, slot {slot} is missing capability {capability}"
139            ),
140            Self::UnknownExposureProvider { provider } => {
141                write!(formatter, "unknown exposure provider: {provider}")
142            }
143            Self::UnknownExposedCapability {
144                provider,
145                capability,
146            } => write!(
147                formatter,
148                "unknown exposed capability {capability} for provider {provider}"
149            ),
150            Self::ExposureExceedsMaximum {
151                capability,
152                requested,
153                maximum,
154            } => write!(
155                formatter,
156                "exposure {} exceeds maximum {} for capability {capability}",
157                level_name(*requested),
158                level_name(*maximum)
159            ),
160            Self::TransportConformanceFailed { capability, detail } => write!(
161                formatter,
162                "transport conformance failed for capability {capability}: {detail}"
163            ),
164            Self::TransportPrepareFailed { detail } => {
165                write!(formatter, "transport prepare failed: {detail}")
166            }
167            Self::TransportStartFailed { detail } => {
168                write!(formatter, "transport start failed: {detail}")
169            }
170        }
171    }
172}
173
174fn level_name(level: ExposureLevel) -> &'static str {
175    match level {
176        ExposureLevel::CodeOnly => "code-only",
177        ExposureLevel::Internal => "internal",
178        ExposureLevel::External => "external",
179    }
180}
181
182impl Error for AssemblyError {}
183
184/// A nonempty, ordered collection of composition-assembly failures.
185#[derive(Debug, Clone, PartialEq, Eq)]
186pub struct AssemblyErrors(Box<[AssemblyError]>);
187
188impl AssemblyErrors {
189    /// Returns failures in deterministic validation order.
190    pub fn errors(&self) -> &[AssemblyError] {
191        &self.0
192    }
193
194    pub(crate) fn from_errors(errors: Vec<AssemblyError>) -> Option<Self> {
195        (!errors.is_empty()).then(|| Self(errors.into_boxed_slice()))
196    }
197}
198
199impl fmt::Display for AssemblyErrors {
200    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
201        for (index, error) in self.errors().iter().enumerate() {
202            if index != 0 {
203                formatter.write_str("\n")?;
204            }
205            write!(formatter, "{error}")?;
206        }
207        Ok(())
208    }
209}
210
211impl Error for AssemblyErrors {}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use boxology_contract::CapabilityName;
217
218    fn box_id(value: &str) -> BoxId {
219        BoxId::new(value).unwrap()
220    }
221
222    fn capability(package: &str, name: &str) -> CapabilityId {
223        CapabilityId::new(box_id(package), CapabilityName::new(name).unwrap())
224    }
225
226    fn failure_cases() -> [(AssemblyError, &'static str); 13] {
227        [
228            (
229                AssemblyError::DuplicateBox {
230                    box_id: box_id("duplicate"),
231                },
232                "duplicate box registration: duplicate",
233            ),
234            (
235                AssemblyError::UnknownImportConsumer {
236                    consumer: box_id("unknown-consumer"),
237                },
238                "unknown import consumer: unknown-consumer",
239            ),
240            (
241                AssemblyError::UnknownImportSlot {
242                    consumer: box_id("consumer"),
243                    slot: box_id("unknown-slot"),
244                },
245                "unknown import slot unknown-slot for consumer consumer",
246            ),
247            (
248                AssemblyError::DuplicateImportResolution {
249                    consumer: box_id("consumer"),
250                    slot: box_id("duplicate-slot"),
251                },
252                "duplicate import resolution for consumer consumer, slot duplicate-slot",
253            ),
254            (
255                AssemblyError::UnknownImportTarget {
256                    consumer: box_id("consumer"),
257                    slot: box_id("slot"),
258                    target: box_id("unknown-target"),
259                },
260                "unknown import target unknown-target for consumer consumer, slot slot",
261            ),
262            (
263                AssemblyError::MissingImportResolution {
264                    consumer: box_id("consumer"),
265                    slot: box_id("missing-slot"),
266                },
267                "missing import resolution for consumer consumer, slot missing-slot",
268            ),
269            (
270                AssemblyError::MissingImportedCapability {
271                    consumer: box_id("consumer"),
272                    slot: box_id("slot"),
273                    capability: capability("target", "needed"),
274                },
275                "import target for consumer consumer, slot slot is missing capability target.needed",
276            ),
277            (
278                AssemblyError::UnknownExposureProvider {
279                    provider: box_id("unknown-provider"),
280                },
281                "unknown exposure provider: unknown-provider",
282            ),
283            (
284                AssemblyError::UnknownExposedCapability {
285                    provider: box_id("provider"),
286                    capability: capability("provider", "unknown"),
287                },
288                "unknown exposed capability provider.unknown for provider provider",
289            ),
290            (
291                AssemblyError::ExposureExceedsMaximum {
292                    capability: capability("provider", "limited"),
293                    requested: ExposureLevel::External,
294                    maximum: ExposureLevel::Internal,
295                },
296                "exposure external exceeds maximum internal for capability provider.limited",
297            ),
298            (
299                AssemblyError::TransportConformanceFailed {
300                    capability: capability("provider", "rejected"),
301                    detail: Detail::new("test_conformance"),
302                },
303                "transport conformance failed for capability provider.rejected: test_conformance",
304            ),
305            (
306                AssemblyError::TransportPrepareFailed {
307                    detail: Detail::new("test_prepare"),
308                },
309                "transport prepare failed: test_prepare",
310            ),
311            (
312                AssemblyError::TransportStartFailed {
313                    detail: Detail::new("test_start"),
314                },
315                "transport start failed: test_start",
316            ),
317        ]
318    }
319
320    #[test]
321    fn every_failure_is_cloneable_equal_and_has_exact_display() {
322        for (error, expected) in failure_cases() {
323            assert_eq!(error, error.clone());
324            assert_eq!(error.to_string(), expected);
325        }
326        assert_eq!(level_name(ExposureLevel::CodeOnly), "code-only");
327    }
328
329    #[test]
330    fn diagnostics_satisfy_error_and_thread_safety_bounds() {
331        fn assert_error<T: Error>() {}
332        fn assert_bounds<T: Send + Sync + 'static>() {}
333
334        assert_error::<AssemblyError>();
335        assert_error::<AssemblyErrors>();
336        assert_bounds::<AssemblyError>();
337        assert_bounds::<AssemblyErrors>();
338    }
339
340    #[test]
341    fn empty_failure_collection_is_absent() {
342        assert_eq!(AssemblyErrors::from_errors(Vec::new()), None);
343    }
344
345    #[test]
346    fn aggregate_preserves_deliberately_shuffled_order_exactly() {
347        let cases = failure_cases();
348        let shuffled = [12, 6, 0, 4, 5, 3, 1, 2, 9, 7, 11, 8, 10];
349        let ordered = shuffled.map(|index| cases[index].0.clone()).to_vec();
350        let aggregate = AssemblyErrors::from_errors(ordered.clone()).unwrap();
351
352        assert_eq!(aggregate.errors(), ordered);
353        assert_eq!(aggregate, aggregate.clone());
354        let expected = shuffled.map(|index| cases[index].1).join("\n");
355        let rendered = aggregate.to_string();
356        assert_eq!(rendered, expected);
357        assert!(!rendered.ends_with('\n'));
358    }
359}