1#![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
38pub struct CheckService {
40 classifier: generated::ClassifierImport,
41}
42
43impl CheckService {
44 pub fn new(classifier: generated::ClassifierImport) -> Self {
46 Self { classifier }
47 }
48}
49
50#[boxology::implementation]
51impl CheckService {
52 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 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#[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}