Skip to main content

boxology_cli/
lib.rs

1//! Compatibility facade for the effectful Boxology command core.
2//!
3//! The installed `boxology` binary remains in this package. Reusable command behavior lives in
4//! `boxology-cli-core` and is re-exported here so existing library consumers keep the same seam.
5#![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
38/// Live local classifier and check boxes assembled for the installed CLI.
39pub struct CheckComposition {
40    _composition: Composition,
41    handle: CheckHandle,
42}
43
44impl CheckComposition {
45    /// Assembles check behind its generated typed handle and resolves its classifier import locally.
46    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    /// Runs check through the generated handle with the installed CLI's exact workspace request.
95    pub fn check(&self, base: Option<String>) -> Result<CheckOutcome, String> {
96        invoke_check(&self.handle, base)
97    }
98}
99
100/// Byte streams and exit status projected from the typed check boundary.
101#[doc(hidden)]
102#[derive(Debug, PartialEq, Eq)]
103pub struct CheckProjection {
104    /// Process exit status.
105    pub code: u8,
106    /// Bytes written to standard output.
107    pub stdout: Vec<u8>,
108    /// Bytes written to standard error.
109    pub stderr: Vec<u8>,
110}
111
112/// Invokes a generated check handle with the installed CLI's exact request shape.
113#[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/// Projects a typed check outcome to the installed CLI's legacy streams and status.
127#[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
180/// Live local classifier box assembled for the CLI composition.
181pub struct ClassifierComposition {
182    _composition: Composition,
183    handle: ClassifierHandle,
184}
185
186impl ClassifierComposition {
187    /// Assembles the classifier implementation behind its generated typed handle.
188    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    /// Classifies canonical schema bytes through the generated handle.
223    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}