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,
8    base_diff_inputs_with_candidate, base_package_schemas, cargo_metadata_command, compare_plans,
9    composition_step, plan, resolve_base, resolve_default_base, run_clippy_step, run_command,
10    run_fmt_step, run_lock_step, 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, bootstrap_diff_ownership, 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, &walked) {
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    candidate: &boxology_cli_core::WalkedWorkspace,
262) -> Result<DiffOwnershipCompletion, Box<CheckOutcome>> {
263    let inputs =
264        base_diff_inputs_with_candidate(root, base, candidate.files(), candidate.manifests())
265            .map_err(|error| Box::new(base_inputs_failure(error)))?;
266    // Release transactions intentionally span the exact closure guarded by `xtask release`.
267    if inputs
268        .changed()
269        .iter()
270        .any(|path| path.as_str() == "crates/xtask/src/release.rs")
271    {
272        return Ok(DiffOwnershipCompletion::Passed);
273    }
274    let ownership = if inputs.is_bootstrapping() {
275        bootstrap_diff_ownership(inputs.packages(), inputs.changed())
276    } else {
277        diff_ownership(inputs.packages(), inputs.changed())
278    };
279    let pairs = inputs
280        .manifest_changes(root, &ownership)
281        .map_err(|error| Box::new(base_inputs_failure(error)))?;
282    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")))?;
283    let (_, _, found) = ownership.into_parts();
284    let mut entries = found.map(Findings::into_entries).unwrap_or_default();
285    if let Some(scope) = scope {
286        entries.extend(scope.into_entries());
287    }
288    Ok(match Findings::new(entries) {
289        Some(value) => DiffOwnershipCompletion::Failed(value),
290        None => DiffOwnershipCompletion::Passed,
291    })
292}
293
294#[rustfmt::skip]
295fn difference_finding(workspace: &Workspace, difference: &CompareDifference) -> Finding {
296    let package = workspace.packages().iter().find(|package| package.id() == difference.package()).expect("difference package exists");
297    let path = match package.root() {
298        Some(root) => RelativePath::new(format!("{}/{}", root.as_str(), difference.path().as_str())).expect("valid relative path"),
299        None => difference.path().clone(),
300    };
301    Finding::external(difference.code(), difference.detail(), difference.rule_source(), path,
302        Some(difference.package().clone()), format!("kind={} repair=\"{}\"", difference.kind().as_str(), difference.repair_command()))
303}
304
305fn read_metadata(root: &Path) -> Result<String, Vec<u8>> {
306    let output = cargo_metadata_command(root)
307        .output()
308        .map_err(|_| metadata_bytes(Vec::new()))?;
309    let Output {
310        status,
311        stdout,
312        stderr,
313    } = output;
314    if !status.success() {
315        return Err(metadata_bytes(stderr));
316    }
317    String::from_utf8(stdout).map_err(|_| metadata_bytes(stderr))
318}
319fn metadata_bytes(stderr: Vec<u8>) -> Vec<u8> {
320    let mut bytes = b"BXW0075 Cargo.toml: cargo metadata could not be executed or did not return valid workspace metadata\n".to_vec();
321    bytes.extend(stderr);
322    if bytes.last() != Some(&b'\n') {
323        bytes.push(b'\n');
324    }
325    bytes
326}
327fn tool(
328    result: Result<boxology_cli_core::ToolStep, SpawnError>,
329) -> Result<(Completion, Option<Vec<u8>>), Box<CheckOutcome>> {
330    result
331        .map(boxology_cli_core::ToolStep::into_parts)
332        .map_err(|error| Box::new(invocation(error.to_string())))
333}
334#[rustfmt::skip]
335fn classification_failure(package: &BoxId, stage: ClassifyFailureStage, diagnostics: String) -> CheckOutcome {
336    let (code, side, detail) = match stage {
337        ClassifyFailureStage::Base => ("BXW0080", "base", "the base-revision schema document must satisfy the strict format-1 reader"),
338        ClassifyFailureStage::Submitted => ("BXW0081", "submitted", "the checked-in schema document must satisfy the strict format-1 reader"),
339        ClassifyFailureStage::Pairing => ("BXW0082", "pairing", "the base-revision and checked-in schema documents must pair and satisfy classifier integrity"),
340        ClassifyFailureStage::Unknown { .. } => unreachable!(),
341    };
342    validation(format!("{code} {package} {side}: {detail}: {diagnostics}"))
343}
344#[rustfmt::skip]
345fn base_failure(error: BaseSchemasError) -> CheckOutcome {
346    match error {
347        BaseSchemasError::Tool(error) => invocation(error.to_string()),
348        other => validation(match other {
349            BaseSchemasError::Git(error) => error.to_string(),
350            BaseSchemasError::Submitted(error) => error.to_string(),
351            BaseSchemasError::Tool(_) => unreachable!(),
352        }),
353    }
354}
355#[rustfmt::skip]
356fn base_inputs_failure(error: BaseInputsError) -> CheckOutcome {
357    match error {
358        BaseInputsError::Tool(error) => invocation(error.to_string()),
359        other => validation(other.to_string()),
360    }
361}
362#[rustfmt::skip]
363fn plan_failure(error: PlanError) -> CheckOutcome {
364    failure(
365        if error.is_unknown_package() { CheckFailureKind::Invocation } else { CheckFailureKind::Validation },
366        line(error.to_string()),
367        error.render_json().into_bytes(),
368    )
369}
370#[rustfmt::skip]
371fn execute_failure(error: ExecuteError) -> CheckOutcome {
372    let human = line(error.to_string());
373    let json = error.diagnostics().map_or_else(|| human.clone(), |value| value.render_json().into_bytes());
374    failure(CheckFailureKind::Validation, human, json)
375}
376#[rustfmt::skip]
377fn validation(text: impl Into<String>) -> CheckOutcome {
378    let bytes = line(text.into());
379    failure(CheckFailureKind::Validation, bytes.clone(), bytes)
380}
381#[rustfmt::skip]
382fn invocation(text: impl Into<String>) -> CheckOutcome {
383    let bytes = line(text.into());
384    failure(CheckFailureKind::Invocation, bytes.clone(), bytes)
385}
386#[rustfmt::skip]
387fn line(mut text: String) -> Vec<u8> {
388    if !text.ends_with('\n') {
389        text.push('\n');
390    }
391    text.into_bytes()
392}
393#[rustfmt::skip]
394fn failure(kind: CheckFailureKind, human: Vec<u8>, json: Vec<u8>) -> CheckOutcome {
395    CheckOutcome {
396        report: None,
397        failure: Some(CheckFailure { kind, human, json }),
398    }
399}
400
401#[rustfmt::skip]
402fn report(report: WorkspaceReport) -> CheckOutcome {
403    let human = line(report.render_human());
404    let json = report.render_json().into_bytes();
405    let value: Value = serde_json::from_slice(&json).expect("canonical check JSON parses");
406    let outputs = [&report.external_output.cargo_graph, &report.external_output.fmt,
407        &report.external_output.clippy, &report.external_output.tests, &report.external_output.quality];
408    let mut output_index = 0;
409    let steps = value["steps"].as_array().expect("steps array").iter().map(|step| {
410            let id = string(step, "id");
411            let output = if matches!(id.as_str(), "cargo-graph" | "fmt" | "clippy" | "tests" | "quality") {
412                let value = outputs[output_index].clone();
413                output_index += 1;
414                value
415            } else {
416                None
417            };
418            CheckStepReport {
419                id,
420                status: match step["status"].as_str() { Some("passed") => CheckStepStatus::Passed,
421                    Some("failed") => CheckStepStatus::Failed, Some("skipped") => CheckStepStatus::Skipped, _ => unreachable!() },
422                reason: optional_string(step, "reason"),
423                findings: step["findings"].as_array().expect("findings array").iter().map(boundary_finding).collect(),
424                output,
425            }
426        })
427        .collect();
428    let status = match report.status() { WorkspaceStatus::Passed => CheckStatus::Passed, WorkspaceStatus::Failed => CheckStatus::Failed };
429    CheckOutcome { report: Some(CheckReport { steps, status, human, json }), failure: None }
430}
431#[rustfmt::skip]
432fn boundary_finding(value: &Value) -> CheckFinding {
433    let span = &value["span"];
434    CheckFinding {
435        kind: value["kind"].as_str().unwrap_or("classifier").to_owned(),
436        code: string(value, "code"),
437        path: string(value, "path"),
438        package: optional_string(value, "package"),
439        payload: optional_string(value, "payload"),
440        rule: optional_string(value, "rule"),
441        rule_source: optional_string(value, "rule_source"),
442        span_start_line: span["start"]["line"].as_u64(),
443        span_start_column: span["start"]["column"].as_u64(),
444        span_end_line: span["end"]["line"].as_u64(),
445        span_end_column: span["end"]["column"].as_u64(),
446        offending: optional_string(value, "offending"),
447        class: optional_string(value, "class"),
448        condition: optional_string(value, "condition"),
449    }
450}
451#[rustfmt::skip]
452fn string(value: &Value, key: &str) -> String { value[key].as_str().expect("canonical string field").to_owned() }
453fn optional_string(value: &Value, key: &str) -> Option<String> {
454    value[key].as_str().map(str::to_owned)
455}
456
457/// Generated adapter and typed classifier import.
458#[doc(hidden)]
459pub mod generated {
460    include!("generated_adapter.rs");
461}
462
463#[cfg(test)]
464#[rustfmt::skip]
465mod tests {
466    use super::*;
467    use boxology_contract::{OpaquePayload, OpaqueTree};
468    use boxology_import_classifier::{ClassifyFailure, ClassifyReport};
469    use boxology_runtime::{AssemblyError, CompositionBuilder};
470
471    #[test]
472    fn boundary_report_has_canonical_bytes_and_nine_ordered_steps() {
473        let passed = || Completion::Passed;
474        let outcome = super::report(WorkspaceReport {
475            discovery: passed(), regeneration: passed(),
476            contract_classification: ContractClassificationCompletion::Skipped(SkipReason::NoRepository),
477            diff_ownership: DiffOwnershipCompletion::Skipped(DiffOwnershipSkip::NoRepository),
478            cargo_graph: passed(), fmt: passed(), clippy: passed(), tests: passed(), quality: passed(),
479            external_output: ExternalOutput::empty(),
480        });
481        let report = outcome.report.unwrap();
482        assert!(outcome.failure.is_none());
483        assert_eq!(report.status, CheckStatus::Passed);
484        assert_eq!(report.steps.iter().map(|step| step.id.as_str()).collect::<Vec<_>>(), [
485            "discovery", "regeneration", "contract-classification", "diff-ownership",
486            "cargo-graph", "fmt", "clippy", "tests", "quality",
487        ]);
488        assert_eq!(report.human.last(), Some(&b'\n'));
489        assert_eq!(report.json.last(), Some(&b'\n'));
490        assert_eq!(serde_json::from_slice::<Value>(&report.json).unwrap()["result"], "passed");
491    }
492
493    fn unknown() -> OpaquePayload { OpaquePayload::new(OpaqueTree::String("future".into())) }
494    fn report() -> ClassifyReport { ClassifyReport { verdict: CompatibilityClass::Unchanged, findings: Vec::new(), rendered_text: String::new() } }
495    fn failed() -> ClassifyFailure { ClassifyFailure { stage: ClassifyFailureStage::Base, diagnostics: "bad".into() } }
496
497    #[test]
498    fn invalid_and_unknown_classifier_outcomes_fail_closed() {
499        assert!(valid_classifier_outcome(ClassifyOutcome { report: None, failure: None }).is_err());
500        assert!(valid_classifier_outcome(ClassifyOutcome { report: Some(report()), failure: Some(failed()) }).is_err());
501        let verdict = CompatibilityClass::Unknown { tag: "Future".into(), payload: unknown() };
502        assert!(valid_classifier_outcome(ClassifyOutcome { report: Some(ClassifyReport { verdict, ..report() }), failure: None }).is_err());
503        let stage = ClassifyFailureStage::Unknown { tag: "Future".into(), payload: unknown() };
504        assert!(valid_classifier_outcome(ClassifyOutcome { report: None, failure: Some(ClassifyFailure { stage, diagnostics: String::new() }) }).is_err());
505    }
506
507    #[test]
508    fn generated_classifier_import_is_mandatory() {
509        let source = include_str!("lib.rs");
510        let adapter = include_str!("generated_adapter.rs");
511        let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
512        if !root.join("Cargo.toml.orig").is_file() {
513            assert_eq!(adapter.as_bytes(), std::fs::read(root.join("../generated/adapter/adapter.rs")).unwrap());
514        }
515        let direct = ["boxology_classifier", "::classify"].concat();
516        let legacy = ["boxology_cli_core", "::classify_step"].concat();
517        assert!(!source.contains(&direct) && !source.contains(&legacy));
518        assert!(source.contains("generated::ClassifierImport") && adapter.contains("pub struct ClassifierImport"));
519        let descriptor = generated::implementation_descriptor();
520        let mut builder = CompositionBuilder::new();
521        builder.add_box(descriptor, |imports| {
522            let deps = generated::typed_imports(&imports);
523            generated::factory(CheckService::new(deps.classifier), imports)
524        });
525        assert_eq!(builder.validate().unwrap_err().errors(), &[AssemblyError::MissingImportResolution {
526            consumer: BoxId::new("check").unwrap(), slot: BoxId::new("classifier").unwrap(),
527        }]);
528    }
529}