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,
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
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, &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 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#[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}