Skip to main content

check_implementation/
lib.rs

1//! Governed whole-workspace validation boundary.
2#![deny(missing_docs)]
3#![forbid(unsafe_code)]
4
5use boxology_cli_core::{
6    BaseInputsError, BaseSchemasError, CompareDifference, CompareStepError, DefaultBase,
7    ExecuteError, GenerationPlan, PlanError, ResolvedBase, SpawnError, base_diff_inputs,
8    base_package_schemas, cargo_metadata_command, compare_plans, composition_step, plan,
9    resolve_base, resolve_default_base, run_clippy_step, run_command, run_fmt_step, run_lock_step,
10    run_quality_step, run_test_step, walk,
11};
12use boxology_contract::BoxId;
13use boxology_import_classifier::{
14    ClassifyFailureStage, ClassifyOutcome, ClassifyRequest, CompatibilityClass,
15};
16use boxology_manifest::RelativePath;
17use boxology_workspace::{
18    CheckReport as WorkspaceReport, CheckStatus as WorkspaceStatus, ClassificationFinding,
19    ClassificationFindings, Completion, ContractClassificationCompletion, DiffOwnershipCompletion,
20    DiffOwnershipSkip, Entry, ExternalOutput, Finding, Findings, SkipReason, Workspace,
21    WorkspaceInputs, diff_ownership,
22};
23use serde_json::Value;
24use std::{path::Path, process::Output};
25
26macro_rules! check_tool {
27    ($result:expr) => {
28        match tool($result) {
29            Ok(value) => value,
30            Err(outcome) => return Ok(*outcome),
31        }
32    };
33}
34
35mod contract;
36pub use contract::*;
37
38/// Whole-workspace checker with a generated classifier import.
39pub struct CheckService {
40    classifier: generated::ClassifierImport,
41}
42
43impl CheckService {
44    /// Constructs the service from its generated typed dependency.
45    pub fn new(classifier: generated::ClassifierImport) -> Self {
46        Self { classifier }
47    }
48}
49
50#[boxology::implementation]
51impl CheckService {
52    /// Runs the canonical validation sequence rooted at the requested workspace.
53    pub async fn check(
54        &self,
55        context: boxology::CallContext,
56        request: CheckRequest,
57    ) -> Result<CheckOutcome, CheckError> {
58        self.run(
59            &context,
60            Path::new(&request.workspace),
61            request.base.as_deref(),
62        )
63        .await
64    }
65}
66
67impl CheckService {
68    async fn run(
69        &self,
70        context: &boxology::CallContext,
71        root: &Path,
72        base: Option<&str>,
73    ) -> Result<CheckOutcome, CheckError> {
74        let walked = match walk(root) {
75            Ok(value) => value,
76            Err(error) => return Ok(validation(error.to_string())),
77        };
78        let metadata = match read_metadata(root) {
79            Ok(value) => value,
80            Err(bytes) => return Ok(failure(CheckFailureKind::Invocation, bytes.clone(), bytes)),
81        };
82        let inputs = WorkspaceInputs::new(
83            walked.files().to_vec(),
84            walked.manifests().to_vec(),
85            &metadata,
86        )
87        .map_err(|_| CheckError::Internal)?;
88        let workspace = match inputs.check() {
89            Ok(value) => value,
90            Err(findings) => return Ok(validation(findings.to_string())),
91        };
92        let plans = match plan(&workspace, None) {
93            Ok(value) => value,
94            Err(error) => return Ok(plan_failure(error)),
95        };
96        let discovery = match composition_step(root, &workspace, &plans) {
97            Ok(value) => value,
98            Err(error) => return Ok(execute_failure(error)),
99        };
100        let differences = match compare_plans(root, &workspace, &plans) {
101            Ok(value) => value,
102            Err(CompareStepError::Plan(error)) => return Ok(plan_failure(error)),
103            Err(CompareStepError::Execute(error)) => return Ok(execute_failure(error)),
104        };
105        let regeneration = if differences.is_empty() {
106            Completion::Passed
107        } else {
108            let entries = differences
109                .iter()
110                .map(|difference| Entry::Workspace(difference_finding(&workspace, difference)))
111                .collect();
112            Completion::Failed(Findings::new(entries).expect("differences are nonempty"))
113        };
114        let resolved = match resolve_requested_base(root, base) {
115            Ok(value) => value,
116            Err(outcome) => return Ok(*outcome),
117        };
118        let (contract_classification, diff_ownership) = match resolved {
119            Err(reason) => (
120                ContractClassificationCompletion::Skipped(reason),
121                DiffOwnershipCompletion::Skipped(match reason {
122                    SkipReason::NoRepository => DiffOwnershipSkip::NoRepository,
123                    SkipReason::NoMergeBase | SkipReason::Unimplemented => {
124                        DiffOwnershipSkip::NoMergeBase
125                    }
126                }),
127            ),
128            Ok(base) => {
129                let classification = match self
130                    .classify_contracts(context, root, &base, &plans)
131                    .await?
132                {
133                    Ok(value) => value,
134                    Err(outcome) => return Ok(outcome),
135                };
136                let ownership = match diff_ownership_step(root, &base) {
137                    Ok(value) => value,
138                    Err(outcome) => return Ok(*outcome),
139                };
140                (classification, ownership)
141            }
142        };
143        let runner = &run_command;
144        let (cargo_graph, cargo_graph_output) = check_tool!(run_lock_step(runner, root));
145        let (fmt, fmt_output) = check_tool!(run_fmt_step(runner, root, &workspace));
146        let (clippy, clippy_output) = check_tool!(run_clippy_step(runner, root));
147        let (tests, tests_output) = check_tool!(run_test_step(runner, root));
148        let (quality, quality_output) = check_tool!(run_quality_step(runner, root, &workspace));
149        Ok(report(WorkspaceReport {
150            discovery,
151            regeneration,
152            contract_classification,
153            diff_ownership,
154            cargo_graph,
155            fmt,
156            clippy,
157            tests,
158            quality,
159            external_output: ExternalOutput {
160                cargo_graph: cargo_graph_output,
161                fmt: fmt_output,
162                clippy: clippy_output,
163                tests: tests_output,
164                quality: quality_output,
165            },
166        }))
167    }
168
169    async fn classify_contracts(
170        &self,
171        context: &boxology::CallContext,
172        root: &Path,
173        base: &ResolvedBase,
174        plans: &[GenerationPlan],
175    ) -> Result<Result<ContractClassificationCompletion, CheckOutcome>, CheckError> {
176        let mut schemas = match base_package_schemas(root, base, plans) {
177            Ok(value) => value,
178            Err(error) => return Ok(Err(base_failure(error))),
179        };
180        schemas.sort_by(|left, right| left.package().cmp(right.package()));
181        let mut findings = Vec::new();
182        for schema in schemas {
183            let package = schema.package().clone();
184            let request = ClassifyRequest {
185                base: schema.base().map(<[u8]>::to_vec),
186                submitted: schema.submitted().to_vec(),
187            };
188            let outcome = self
189                .classifier
190                .classify(context.child(), request)
191                .await
192                .map_err(|_| CheckError::Internal)?;
193            match valid_classifier_outcome(outcome)? {
194                Ok(report) => {
195                    for finding in report.findings {
196                        findings.push(ClassificationFinding::new(
197                            package.clone(),
198                            finding.path,
199                            finding.code,
200                            class_name(finding.class)?,
201                            finding.condition,
202                        ));
203                    }
204                }
205                Err((stage, diagnostics)) => {
206                    return Ok(Err(classification_failure(&package, stage, diagnostics)));
207                }
208            }
209        }
210        Ok(Ok(match ClassificationFindings::new(findings) {
211            Some(value) => ContractClassificationCompletion::Failed(value),
212            None => ContractClassificationCompletion::Passed,
213        }))
214    }
215}
216
217type ClassifierResult =
218    Result<boxology_import_classifier::ClassifyReport, (ClassifyFailureStage, String)>;
219#[rustfmt::skip]
220fn valid_classifier_outcome(outcome: ClassifyOutcome) -> Result<ClassifierResult, CheckError> {
221    match (outcome.report, outcome.failure) {
222        (Some(report), None) if class_name(report.verdict.clone()).is_ok()
223            && report.findings.iter().all(|finding| class_name(finding.class.clone()).is_ok()) => Ok(Ok(report)),
224        (None, Some(failure)) if !matches!(failure.stage, ClassifyFailureStage::Unknown { .. }) => Ok(Err((failure.stage, failure.diagnostics))),
225        _ => Err(CheckError::Internal),
226    }
227}
228
229#[rustfmt::skip]
230fn class_name(class: CompatibilityClass) -> Result<String, CheckError> {
231    Ok(match class {
232        CompatibilityClass::Unchanged => "unchanged",
233        CompatibilityClass::Documentation => "documentation",
234        CompatibilityClass::Deprecation => "deprecation",
235        CompatibilityClass::Additive => "additive",
236        CompatibilityClass::CompatibleWithConditions => "compatible_with_conditions",
237        CompatibilityClass::Incompatible => "incompatible",
238        CompatibilityClass::Unknown { .. } => return Err(CheckError::Internal),
239    }
240    .to_owned())
241}
242
243#[rustfmt::skip]
244fn resolve_requested_base(root: &Path, base: Option<&str>) -> Result<Result<ResolvedBase, SkipReason>, Box<CheckOutcome>> {
245    match base {
246        None => match resolve_default_base(root) {
247            Ok(DefaultBase::NoRepository) => Ok(Err(SkipReason::NoRepository)),
248            Ok(DefaultBase::NoMergeBase) => Ok(Err(SkipReason::NoMergeBase)),
249            Ok(DefaultBase::Commit(oid)) => ResolvedBase::from_oid(oid)
250                .map(Ok)
251                .map_err(|error| Box::new(validation(error.to_string()))),
252            Err(error) => Err(Box::new(invocation(error.to_string()))),
253        },
254        Some(revision) => resolve_base(root, revision).map(Ok).map_err(|error| Box::new(base_failure(error))),
255    }
256}
257
258fn diff_ownership_step(
259    root: &Path,
260    base: &ResolvedBase,
261) -> Result<DiffOwnershipCompletion, Box<CheckOutcome>> {
262    let inputs =
263        base_diff_inputs(root, base).map_err(|error| Box::new(base_inputs_failure(error)))?;
264    // Release transactions intentionally span the exact closure guarded by `xtask release`.
265    if inputs
266        .changed()
267        .iter()
268        .any(|path| path.as_str() == "crates/xtask/src/release.rs")
269    {
270        return Ok(DiffOwnershipCompletion::Passed);
271    }
272    let ownership = diff_ownership(inputs.packages(), inputs.changed());
273    let pairs = inputs
274        .manifest_changes(root, &ownership)
275        .map_err(|error| Box::new(base_inputs_failure(error)))?;
276    let scope = ownership.lockfile_scope(&pairs).map_err(|_| Box::new(validation("BXW0103 .git: the base revision's Git listings must parse as expected NUL-delimited output")))?;
277    let (_, _, found) = ownership.into_parts();
278    let mut entries = found.map(Findings::into_entries).unwrap_or_default();
279    if let Some(scope) = scope {
280        entries.extend(scope.into_entries());
281    }
282    Ok(match Findings::new(entries) {
283        Some(value) => DiffOwnershipCompletion::Failed(value),
284        None => DiffOwnershipCompletion::Passed,
285    })
286}
287
288#[rustfmt::skip]
289fn difference_finding(workspace: &Workspace, difference: &CompareDifference) -> Finding {
290    let package = workspace.packages().iter().find(|package| package.id() == difference.package()).expect("difference package exists");
291    let path = match package.root() {
292        Some(root) => RelativePath::new(format!("{}/{}", root.as_str(), difference.path().as_str())).expect("valid relative path"),
293        None => difference.path().clone(),
294    };
295    Finding::external(difference.code(), difference.detail(), difference.rule_source(), path,
296        Some(difference.package().clone()), format!("kind={} repair=\"{}\"", difference.kind().as_str(), difference.repair_command()))
297}
298
299fn read_metadata(root: &Path) -> Result<String, Vec<u8>> {
300    let output = cargo_metadata_command(root)
301        .output()
302        .map_err(|_| metadata_bytes(Vec::new()))?;
303    let Output {
304        status,
305        stdout,
306        stderr,
307    } = output;
308    if !status.success() {
309        return Err(metadata_bytes(stderr));
310    }
311    String::from_utf8(stdout).map_err(|_| metadata_bytes(stderr))
312}
313fn metadata_bytes(stderr: Vec<u8>) -> Vec<u8> {
314    let mut bytes = b"BXW0075 Cargo.toml: cargo metadata could not be executed or did not return valid workspace metadata\n".to_vec();
315    bytes.extend(stderr);
316    if bytes.last() != Some(&b'\n') {
317        bytes.push(b'\n');
318    }
319    bytes
320}
321fn tool(
322    result: Result<boxology_cli_core::ToolStep, SpawnError>,
323) -> Result<(Completion, Option<Vec<u8>>), Box<CheckOutcome>> {
324    result
325        .map(boxology_cli_core::ToolStep::into_parts)
326        .map_err(|error| Box::new(invocation(error.to_string())))
327}
328#[rustfmt::skip]
329fn classification_failure(package: &BoxId, stage: ClassifyFailureStage, diagnostics: String) -> CheckOutcome {
330    let (code, side, detail) = match stage {
331        ClassifyFailureStage::Base => ("BXW0080", "base", "the base-revision schema document must satisfy the strict format-1 reader"),
332        ClassifyFailureStage::Submitted => ("BXW0081", "submitted", "the checked-in schema document must satisfy the strict format-1 reader"),
333        ClassifyFailureStage::Pairing => ("BXW0082", "pairing", "the base-revision and checked-in schema documents must pair and satisfy classifier integrity"),
334        ClassifyFailureStage::Unknown { .. } => unreachable!(),
335    };
336    validation(format!("{code} {package} {side}: {detail}: {diagnostics}"))
337}
338#[rustfmt::skip]
339fn base_failure(error: BaseSchemasError) -> CheckOutcome {
340    match error {
341        BaseSchemasError::Tool(error) => invocation(error.to_string()),
342        other => validation(match other {
343            BaseSchemasError::Git(error) => error.to_string(),
344            BaseSchemasError::Submitted(error) => error.to_string(),
345            BaseSchemasError::Tool(_) => unreachable!(),
346        }),
347    }
348}
349#[rustfmt::skip]
350fn base_inputs_failure(error: BaseInputsError) -> CheckOutcome {
351    match error {
352        BaseInputsError::Tool(error) => invocation(error.to_string()),
353        other => validation(other.to_string()),
354    }
355}
356#[rustfmt::skip]
357fn plan_failure(error: PlanError) -> CheckOutcome {
358    failure(
359        if error.is_unknown_package() { CheckFailureKind::Invocation } else { CheckFailureKind::Validation },
360        line(error.to_string()),
361        error.render_json().into_bytes(),
362    )
363}
364#[rustfmt::skip]
365fn execute_failure(error: ExecuteError) -> CheckOutcome {
366    let human = line(error.to_string());
367    let json = error.diagnostics().map_or_else(|| human.clone(), |value| value.render_json().into_bytes());
368    failure(CheckFailureKind::Validation, human, json)
369}
370#[rustfmt::skip]
371fn validation(text: impl Into<String>) -> CheckOutcome {
372    let bytes = line(text.into());
373    failure(CheckFailureKind::Validation, bytes.clone(), bytes)
374}
375#[rustfmt::skip]
376fn invocation(text: impl Into<String>) -> CheckOutcome {
377    let bytes = line(text.into());
378    failure(CheckFailureKind::Invocation, bytes.clone(), bytes)
379}
380#[rustfmt::skip]
381fn line(mut text: String) -> Vec<u8> {
382    if !text.ends_with('\n') {
383        text.push('\n');
384    }
385    text.into_bytes()
386}
387#[rustfmt::skip]
388fn failure(kind: CheckFailureKind, human: Vec<u8>, json: Vec<u8>) -> CheckOutcome {
389    CheckOutcome {
390        report: None,
391        failure: Some(CheckFailure { kind, human, json }),
392    }
393}
394
395#[rustfmt::skip]
396fn report(report: WorkspaceReport) -> CheckOutcome {
397    let human = line(report.render_human());
398    let json = report.render_json().into_bytes();
399    let value: Value = serde_json::from_slice(&json).expect("canonical check JSON parses");
400    let outputs = [&report.external_output.cargo_graph, &report.external_output.fmt,
401        &report.external_output.clippy, &report.external_output.tests, &report.external_output.quality];
402    let mut output_index = 0;
403    let steps = value["steps"].as_array().expect("steps array").iter().map(|step| {
404            let id = string(step, "id");
405            let output = if matches!(id.as_str(), "cargo-graph" | "fmt" | "clippy" | "tests" | "quality") {
406                let value = outputs[output_index].clone();
407                output_index += 1;
408                value
409            } else {
410                None
411            };
412            CheckStepReport {
413                id,
414                status: match step["status"].as_str() { Some("passed") => CheckStepStatus::Passed,
415                    Some("failed") => CheckStepStatus::Failed, Some("skipped") => CheckStepStatus::Skipped, _ => unreachable!() },
416                reason: optional_string(step, "reason"),
417                findings: step["findings"].as_array().expect("findings array").iter().map(boundary_finding).collect(),
418                output,
419            }
420        })
421        .collect();
422    let status = match report.status() { WorkspaceStatus::Passed => CheckStatus::Passed, WorkspaceStatus::Failed => CheckStatus::Failed };
423    CheckOutcome { report: Some(CheckReport { steps, status, human, json }), failure: None }
424}
425#[rustfmt::skip]
426fn boundary_finding(value: &Value) -> CheckFinding {
427    let span = &value["span"];
428    CheckFinding {
429        kind: value["kind"].as_str().unwrap_or("classifier").to_owned(),
430        code: string(value, "code"),
431        path: string(value, "path"),
432        package: optional_string(value, "package"),
433        payload: optional_string(value, "payload"),
434        rule: optional_string(value, "rule"),
435        rule_source: optional_string(value, "rule_source"),
436        span_start_line: span["start"]["line"].as_u64(),
437        span_start_column: span["start"]["column"].as_u64(),
438        span_end_line: span["end"]["line"].as_u64(),
439        span_end_column: span["end"]["column"].as_u64(),
440        offending: optional_string(value, "offending"),
441        class: optional_string(value, "class"),
442        condition: optional_string(value, "condition"),
443    }
444}
445#[rustfmt::skip]
446fn string(value: &Value, key: &str) -> String { value[key].as_str().expect("canonical string field").to_owned() }
447fn optional_string(value: &Value, key: &str) -> Option<String> {
448    value[key].as_str().map(str::to_owned)
449}
450
451/// Generated adapter and typed classifier import.
452#[doc(hidden)]
453pub mod generated {
454    include!("generated_adapter.rs");
455}
456
457#[cfg(test)]
458#[rustfmt::skip]
459mod tests {
460    use super::*;
461    use boxology_contract::{OpaquePayload, OpaqueTree};
462    use boxology_import_classifier::{ClassifyFailure, ClassifyReport};
463    use boxology_runtime::{AssemblyError, CompositionBuilder};
464
465    #[test]
466    fn boundary_report_has_canonical_bytes_and_nine_ordered_steps() {
467        let passed = || Completion::Passed;
468        let outcome = super::report(WorkspaceReport {
469            discovery: passed(), regeneration: passed(),
470            contract_classification: ContractClassificationCompletion::Skipped(SkipReason::NoRepository),
471            diff_ownership: DiffOwnershipCompletion::Skipped(DiffOwnershipSkip::NoRepository),
472            cargo_graph: passed(), fmt: passed(), clippy: passed(), tests: passed(), quality: passed(),
473            external_output: ExternalOutput::empty(),
474        });
475        let report = outcome.report.unwrap();
476        assert!(outcome.failure.is_none());
477        assert_eq!(report.status, CheckStatus::Passed);
478        assert_eq!(report.steps.iter().map(|step| step.id.as_str()).collect::<Vec<_>>(), [
479            "discovery", "regeneration", "contract-classification", "diff-ownership",
480            "cargo-graph", "fmt", "clippy", "tests", "quality",
481        ]);
482        assert_eq!(report.human.last(), Some(&b'\n'));
483        assert_eq!(report.json.last(), Some(&b'\n'));
484        assert_eq!(serde_json::from_slice::<Value>(&report.json).unwrap()["result"], "passed");
485    }
486
487    fn unknown() -> OpaquePayload { OpaquePayload::new(OpaqueTree::String("future".into())) }
488    fn report() -> ClassifyReport { ClassifyReport { verdict: CompatibilityClass::Unchanged, findings: Vec::new(), rendered_text: String::new() } }
489    fn failed() -> ClassifyFailure { ClassifyFailure { stage: ClassifyFailureStage::Base, diagnostics: "bad".into() } }
490
491    #[test]
492    fn invalid_and_unknown_classifier_outcomes_fail_closed() {
493        assert!(valid_classifier_outcome(ClassifyOutcome { report: None, failure: None }).is_err());
494        assert!(valid_classifier_outcome(ClassifyOutcome { report: Some(report()), failure: Some(failed()) }).is_err());
495        let verdict = CompatibilityClass::Unknown { tag: "Future".into(), payload: unknown() };
496        assert!(valid_classifier_outcome(ClassifyOutcome { report: Some(ClassifyReport { verdict, ..report() }), failure: None }).is_err());
497        let stage = ClassifyFailureStage::Unknown { tag: "Future".into(), payload: unknown() };
498        assert!(valid_classifier_outcome(ClassifyOutcome { report: None, failure: Some(ClassifyFailure { stage, diagnostics: String::new() }) }).is_err());
499    }
500
501    #[test]
502    fn generated_classifier_import_is_mandatory() {
503        let source = include_str!("lib.rs");
504        let adapter = include_str!("generated_adapter.rs");
505        let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
506        if !root.join("Cargo.toml.orig").is_file() {
507            assert_eq!(adapter.as_bytes(), std::fs::read(root.join("../generated/adapter/adapter.rs")).unwrap());
508        }
509        let direct = ["boxology_classifier", "::classify"].concat();
510        let legacy = ["boxology_cli_core", "::classify_step"].concat();
511        assert!(!source.contains(&direct) && !source.contains(&legacy));
512        assert!(source.contains("generated::ClassifierImport") && adapter.contains("pub struct ClassifierImport"));
513        let descriptor = generated::implementation_descriptor();
514        let mut builder = CompositionBuilder::new();
515        builder.add_box(descriptor, |imports| {
516            let deps = generated::typed_imports(&imports);
517            generated::factory(CheckService::new(deps.classifier), imports)
518        });
519        assert_eq!(builder.validate().unwrap_err().errors(), &[AssemblyError::MissingImportResolution {
520            consumer: BoxId::new("check").unwrap(), slot: BoxId::new("classifier").unwrap(),
521        }]);
522    }
523}