1#![deny(missing_docs)]
6#![forbid(unsafe_code)]
7
8pub use boxology_cli_core::*;
9
10use std::{
11 future::Future,
12 pin::{Pin, pin},
13 sync::{Arc, Mutex, Weak},
14 task::{Context, Poll, Waker},
15};
16
17use boxology_contract::{
18 BoxId, CallContext, CallError, Caller, CancelToken, CapabilityDescriptor, CapabilityId,
19 CapabilityShape, Detail, ErasedCallError, ErasedCallTarget, ExposureLevel, SlotValue,
20 TraceContext,
21};
22use boxology_runtime::{
23 Composition, CompositionBuilder, ImportTarget, TransportBinding, TransportExposure,
24 TransportHandle, TransportJoinFuture, TransportRuntime,
25};
26use check_contract::{
27 CheckFailureKind, CheckHandle, CheckOutcome, CheckRequest, CheckStatus, CheckStepStatus,
28};
29use classifier_contract::{
30 ClassifierError, ClassifierHandle, ClassifyFailure, ClassifyFailureStage, ClassifyOutcome,
31 ClassifyReport, ClassifyRequest, CompatibilityClass,
32};
33
34const INVALID_CLASSIFIER_OUTCOME: &str = "classifier call failed: invalid classifier outcome";
35const INVALID_CHECK_OUTCOME: &str = "check call failed: invalid check outcome\n";
36const CHECK_CALL_FAILED: &str = "check call failed\n";
37
38pub struct CheckComposition {
40 _composition: Composition,
41 handle: CheckHandle,
42}
43
44impl CheckComposition {
45 pub fn start() -> Result<Self, String> {
47 let classifier = classifier_implementation::generated::implementation_descriptor();
48 let check = check_implementation::generated::implementation_descriptor();
49 let [capability] = check.contract().capabilities() else {
50 return Err("check contract must expose exactly one capability".into());
51 };
52 let binding = Arc::new(LocalBinding::default());
53 let mut builder = CompositionBuilder::new();
54 builder.add_box(classifier, |imports| {
55 classifier_implementation::generated::factory(
56 classifier_implementation::ClassifierService,
57 imports,
58 )
59 });
60 builder.add_box(check, |imports| {
61 let dependencies = check_implementation::generated::typed_imports(&imports);
62 check_implementation::generated::factory(
63 check_implementation::CheckService::new(dependencies.classifier),
64 imports,
65 )
66 });
67 let check_id = BoxId::new("check").expect("check box id is valid");
68 let classifier_id = BoxId::new("classifier").expect("classifier box id is valid");
69 builder.resolve_import(
70 check_id.clone(),
71 classifier_id.clone(),
72 ImportTarget::local(classifier_id),
73 );
74 builder.expose(
75 check_id,
76 capability.id().clone(),
77 binding.clone(),
78 ExposureLevel::CodeOnly,
79 );
80 let composition = builder.start().map_err(|error| error.to_string())?;
81 let runtime = binding
82 .runtime()
83 .ok_or_else(|| "check in-process binding did not start".to_owned())?;
84 let [exposure] = runtime.exposures() else {
85 return Err("check composition must expose exactly one capability".into());
86 };
87 let handle = CheckHandle::from_erased(Arc::new(ExposureTarget(vec![exposure.clone()])));
88 Ok(Self {
89 _composition: composition,
90 handle,
91 })
92 }
93
94 pub fn check(&self, base: Option<String>) -> Result<CheckOutcome, String> {
96 invoke_check(&self.handle, base)
97 }
98}
99
100#[doc(hidden)]
102#[derive(Debug, PartialEq, Eq)]
103pub struct CheckProjection {
104 pub code: u8,
106 pub stdout: Vec<u8>,
108 pub stderr: Vec<u8>,
110}
111
112#[doc(hidden)]
114pub fn invoke_check(handle: &CheckHandle, base: Option<String>) -> Result<CheckOutcome, String> {
115 ready(handle.check(
116 context(),
117 CheckRequest {
118 workspace: ".".into(),
119 base,
120 },
121 ))
122 .map_err(|_| "check call failed".to_owned())?
123 .map_err(|_| "check call failed".to_owned())
124}
125
126#[doc(hidden)]
128pub fn project_check(outcome: Result<CheckOutcome, String>, json: bool) -> CheckProjection {
129 let invalid = || CheckProjection {
130 code: 1,
131 stdout: Vec::new(),
132 stderr: INVALID_CHECK_OUTCOME.as_bytes().to_vec(),
133 };
134 let outcome = match outcome {
135 Ok(value) => value,
136 Err(_) => {
137 return CheckProjection {
138 code: 1,
139 stdout: Vec::new(),
140 stderr: CHECK_CALL_FAILED.as_bytes().to_vec(),
141 };
142 }
143 };
144 match (outcome.report, outcome.failure) {
145 (Some(report), None) => {
146 if report
147 .steps
148 .iter()
149 .any(|step| matches!(step.status, CheckStepStatus::Unknown { .. }))
150 {
151 return invalid();
152 }
153 let code = match report.status {
154 CheckStatus::Passed => 0,
155 CheckStatus::Failed => 1,
156 CheckStatus::Unknown { .. } => return invalid(),
157 };
158 CheckProjection {
159 code,
160 stdout: if json { report.json } else { report.human },
161 stderr: Vec::new(),
162 }
163 }
164 (None, Some(failure)) => {
165 let code = match failure.kind {
166 CheckFailureKind::Validation => 1,
167 CheckFailureKind::Invocation => 2,
168 CheckFailureKind::Unknown { .. } => return invalid(),
169 };
170 CheckProjection {
171 code,
172 stdout: Vec::new(),
173 stderr: if json { failure.json } else { failure.human },
174 }
175 }
176 _ => invalid(),
177 }
178}
179
180pub struct ClassifierComposition {
182 _composition: Composition,
183 handle: ClassifierHandle,
184}
185
186impl ClassifierComposition {
187 pub fn start() -> Result<Self, String> {
189 let descriptor = classifier_implementation::generated::implementation_descriptor();
190 let [capability] = descriptor.contract().capabilities() else {
191 return Err("classifier contract must expose exactly one capability".into());
192 };
193 let binding = Arc::new(LocalBinding::default());
194 let mut builder = CompositionBuilder::new();
195 builder.add_box(descriptor, |imports| {
196 classifier_implementation::generated::factory(
197 classifier_implementation::ClassifierService,
198 imports,
199 )
200 });
201 builder.expose(
202 BoxId::new("classifier").expect("classifier box id is valid"),
203 capability.id().clone(),
204 binding.clone(),
205 ExposureLevel::CodeOnly,
206 );
207 let composition = builder.start().map_err(|error| error.to_string())?;
208 let runtime = binding
209 .runtime()
210 .ok_or_else(|| "classifier in-process binding did not start".to_owned())?;
211 let [exposure] = runtime.exposures() else {
212 return Err("classifier composition must expose exactly one capability".into());
213 };
214 let handle =
215 ClassifierHandle::from_erased(Arc::new(ExposureTarget(vec![exposure.clone()])));
216 Ok(Self {
217 _composition: composition,
218 handle,
219 })
220 }
221
222 pub fn classify(
224 &self,
225 base: Option<&[u8]>,
226 submitted: &[u8],
227 ) -> Result<ClassifyReport, String> {
228 let request = ClassifyRequest {
229 base: base.map(<[u8]>::to_vec),
230 submitted: submitted.to_vec(),
231 };
232 match ready(self.handle.classify(context(), request))? {
233 Ok(outcome) => outcome_report(outcome),
234 Err(CallError::Domain(ClassifierError::Internal | ClassifierError::Unknown { .. })) => {
235 Err(INVALID_CLASSIFIER_OUTCOME.into())
236 }
237 Err(error) => Err(format!("classifier call failed: {error}")),
238 }
239 }
240}
241
242fn outcome_report(outcome: ClassifyOutcome) -> Result<ClassifyReport, String> {
243 match (outcome.report, outcome.failure) {
244 (Some(report), None) if report_classes_are_known(&report) => Ok(report),
245 (None, Some(failure)) => failure_message(failure),
246 _ => Err(INVALID_CLASSIFIER_OUTCOME.into()),
247 }
248}
249
250fn failure_message(failure: ClassifyFailure) -> Result<ClassifyReport, String> {
251 let (code, stage, detail) = match failure.stage {
252 ClassifyFailureStage::Base => (
253 "BXW0077",
254 "base",
255 "the checked-in schema document must satisfy the strict format-1 reader",
256 ),
257 ClassifyFailureStage::Submitted => (
258 "BXW0078",
259 "submitted",
260 "the regenerated schema document must satisfy the strict format-1 reader",
261 ),
262 ClassifyFailureStage::Pairing => (
263 "BXW0079",
264 "pairing",
265 "the checked-in and regenerated schema documents must pair and satisfy classifier integrity",
266 ),
267 ClassifyFailureStage::Unknown { .. } => {
268 return Err(INVALID_CLASSIFIER_OUTCOME.into());
269 }
270 };
271 Err(format!("{code} {stage}: {detail}: {}", failure.diagnostics))
272}
273
274fn report_classes_are_known(report: &ClassifyReport) -> bool {
275 class_is_known(&report.verdict)
276 && report
277 .findings
278 .iter()
279 .all(|finding| class_is_known(&finding.class))
280}
281
282fn class_is_known(class: &CompatibilityClass) -> bool {
283 !matches!(class, CompatibilityClass::Unknown { .. })
284}
285
286fn context() -> CallContext {
287 CallContext::new(
288 Caller::Anonymous,
289 None,
290 CancelToken::new(),
291 TraceContext::empty(),
292 None,
293 )
294}
295
296fn ready<F: Future>(future: F) -> Result<F::Output, String> {
297 let mut future = pin!(future);
298 match future
299 .as_mut()
300 .poll(&mut Context::from_waker(Waker::noop()))
301 {
302 Poll::Ready(output) => Ok(output),
303 Poll::Pending => Err("local generated call unexpectedly pending".into()),
304 }
305}
306
307#[derive(Default)]
308struct LocalBinding {
309 runtime: Mutex<Option<Weak<TransportRuntime<()>>>>,
310}
311
312impl LocalBinding {
313 fn runtime(&self) -> Option<Arc<TransportRuntime<()>>> {
314 self.runtime
315 .lock()
316 .expect("local binding lock poisoned")
317 .as_ref()
318 .and_then(Weak::upgrade)
319 }
320}
321
322struct LocalHandle {
323 _runtime: Arc<TransportRuntime<()>>,
324}
325
326impl TransportHandle for LocalHandle {
327 fn stop_intake(&self) {}
328 fn cancel_tasks(&self) {}
329 fn abort_tasks(&self) {}
330 fn join_tasks(self: Box<Self>) -> TransportJoinFuture {
331 Box::pin(std::future::ready(Ok(())))
332 }
333}
334
335impl TransportBinding for LocalBinding {
336 type Config = ();
337 type Handle = LocalHandle;
338
339 fn config(&self) -> Arc<()> {
340 Arc::new(())
341 }
342
343 fn conform(
344 &self,
345 descriptor: &CapabilityDescriptor,
346 _level: ExposureLevel,
347 ) -> Result<(), Detail> {
348 match descriptor.shape() {
349 CapabilityShape::Unary => Ok(()),
350 _ => Err(Detail::new("unsupported_interaction_shape")),
351 }
352 }
353
354 fn prepare(&self, _descriptors: &[&'static CapabilityDescriptor]) -> Result<(), Detail> {
355 Ok(())
356 }
357
358 fn start(&self, runtime: TransportRuntime<()>) -> Result<LocalHandle, Detail> {
359 let runtime = Arc::new(runtime);
360 let mut retained = self.runtime.lock().expect("local binding lock poisoned");
361 if retained.replace(Arc::downgrade(&runtime)).is_some() {
362 return Err(Detail::new("local_binding_already_started"));
363 }
364 Ok(LocalHandle { _runtime: runtime })
365 }
366}
367
368struct ExposureTarget(Vec<TransportExposure>);
369
370impl ErasedCallTarget for ExposureTarget {
371 fn call<'a>(
372 &'a self,
373 capability: &'a CapabilityId,
374 context: CallContext,
375 input: SlotValue,
376 ) -> Pin<Box<dyn Future<Output = Result<SlotValue, ErasedCallError>> + Send + 'a>> {
377 match self
378 .0
379 .iter()
380 .find(|exposure| exposure.descriptor().id() == capability)
381 {
382 Some(exposure) => exposure.dispatch(context, input),
383 None => Box::pin(std::future::ready(Err(ErasedCallError::Internal(
384 Detail::new("local_capability_mismatch"),
385 )))),
386 }
387 }
388}
389
390#[cfg(test)]
391mod tests {
392 use super::*;
393 use boxology_contract::{OpaquePayload, OpaqueTree};
394
395 fn empty_report(verdict: CompatibilityClass) -> ClassifyReport {
396 ClassifyReport {
397 verdict,
398 findings: Vec::new(),
399 rendered_text: "classification unchanged\n".into(),
400 }
401 }
402
403 fn unknown_class() -> CompatibilityClass {
404 CompatibilityClass::Unknown {
405 tag: "Future".into(),
406 payload: OpaquePayload::new(OpaqueTree::Null),
407 }
408 }
409
410 #[test]
411 fn invalid_or_unknown_classifier_outcomes_fail_internally() {
412 for outcome in [
413 ClassifyOutcome {
414 report: None,
415 failure: None,
416 },
417 ClassifyOutcome {
418 report: Some(empty_report(CompatibilityClass::Unchanged)),
419 failure: Some(ClassifyFailure {
420 stage: ClassifyFailureStage::Base,
421 diagnostics: "diagnostic".into(),
422 }),
423 },
424 ClassifyOutcome {
425 report: Some(empty_report(unknown_class())),
426 failure: None,
427 },
428 ClassifyOutcome {
429 report: None,
430 failure: Some(ClassifyFailure {
431 stage: ClassifyFailureStage::Unknown {
432 tag: "Future".into(),
433 payload: OpaquePayload::new(OpaqueTree::Null),
434 },
435 diagnostics: "diagnostic".into(),
436 }),
437 },
438 ] {
439 assert_eq!(
440 outcome_report(outcome).unwrap_err(),
441 INVALID_CLASSIFIER_OUTCOME
442 );
443 }
444 }
445}