Skip to main content

boxology_cli_core/
check.rs

1//! Pure per-package contract classification for `boxology check` step 3.
2#![deny(missing_docs)]
3#![forbid(unsafe_code)]
4
5use boxology_contract::BoxId;
6use boxology_schema::{Diagnostics, SchemaDocument};
7use boxology_workspace::{
8    ClassificationFinding, ClassificationFindings, ContractClassificationCompletion,
9};
10use std::fmt;
11
12type Rule = (&'static str, &'static str, &'static str);
13const CHECK_D1_SOURCE: &str =
14    "specs/s4-contract-change-classification.md D1; specs/s5-manifest-and-validation.md D6";
15const CHECK_PAIRING_SOURCE: &str =
16    "specs/s4-contract-change-classification.md D2 D6; specs/s5-manifest-and-validation.md D6";
17const CHECK_BASE_TEXT: &str =
18    "the base-revision schema document must satisfy the strict format-1 reader";
19const CHECK_SUBMITTED_TEXT: &str =
20    "the checked-in schema document must satisfy the strict format-1 reader";
21const CHECK_PAIRING_TEXT: &str =
22    "the base-revision and checked-in schema documents must pair and satisfy classifier integrity";
23const CHECK_BASE: Rule = ("BXW0080", CHECK_BASE_TEXT, CHECK_D1_SOURCE);
24const CHECK_SUBMITTED: Rule = ("BXW0081", CHECK_SUBMITTED_TEXT, CHECK_D1_SOURCE);
25const CHECK_PAIRING: Rule = ("BXW0082", CHECK_PAIRING_TEXT, CHECK_PAIRING_SOURCE);
26
27/// Caller supplied the same package id more than once; not a repository defect.
28#[derive(Clone, Copy, Debug, Eq, PartialEq)]
29pub struct DuplicatePackages;
30
31/// Supplied base and checked-in schema bytes for one package.
32#[derive(Clone, Debug, Eq, PartialEq)]
33pub struct PackageSchemas {
34    package: BoxId,
35    base: Option<Vec<u8>>,
36    submitted: Vec<u8>,
37}
38
39impl PackageSchemas {
40    /// Constructs one package's supplied schema bytes.
41    pub fn new(package: BoxId, base: Option<Vec<u8>>, submitted: Vec<u8>) -> Self {
42        Self {
43            package,
44            base,
45            submitted,
46        }
47    }
48
49    /// Returns the package identity.
50    pub fn package(&self) -> &BoxId {
51        &self.package
52    }
53
54    /// Returns the optional base schema bytes.
55    pub fn base(&self) -> Option<&[u8]> {
56        self.base.as_deref()
57    }
58
59    /// Returns the submitted schema bytes.
60    pub fn submitted(&self) -> &[u8] {
61        &self.submitted
62    }
63}
64
65/// A coded check-classification failure with schema or classifier diagnostics.
66#[derive(Debug)]
67pub struct CheckClassificationError {
68    package: BoxId,
69    code: &'static str,
70    side: &'static str,
71    detail: &'static str,
72    diagnostics: Diagnostics,
73}
74
75impl CheckClassificationError {
76    /// Returns the package whose schemas failed.
77    pub fn package(&self) -> &BoxId {
78        &self.package
79    }
80
81    /// Returns the stable `BXW####` code.
82    pub fn code(&self) -> &'static str {
83        self.code
84    }
85
86    /// Returns which stage failed: `base`, `submitted`, or `pairing`.
87    pub fn side(&self) -> &'static str {
88        self.side
89    }
90
91    /// Returns the stable static rule detail.
92    pub fn detail(&self) -> &'static str {
93        self.detail
94    }
95
96    /// Returns the schema or classifier diagnostics unmodified.
97    pub fn diagnostics(&self) -> &Diagnostics {
98        &self.diagnostics
99    }
100
101    fn fail(package: BoxId, rule: Rule, side: &'static str, diagnostics: Diagnostics) -> Self {
102        Self {
103            package,
104            code: rule.0,
105            side,
106            detail: rule.1,
107            diagnostics,
108        }
109    }
110}
111
112impl fmt::Display for CheckClassificationError {
113    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
114        write!(
115            formatter,
116            "{} {} {}: {}: {}",
117            self.code, self.package, self.side, self.detail, self.diagnostics
118        )
119    }
120}
121
122/// Failure from [`classify_step`]: caller misuse or a coded package failure.
123#[derive(Debug)]
124pub enum ClassifyStepError {
125    /// The same package id was supplied more than once.
126    Duplicate(DuplicatePackages),
127    /// A schema parse or classifier integrity failure for one package.
128    Classification(CheckClassificationError),
129}
130
131/// Classifies each package's base-revision and checked-in schema bytes.
132///
133/// # Errors
134/// Returns [`ClassifyStepError::Duplicate`] when a package id appears more than once. Returns
135/// [`ClassifyStepError::Classification`] with `BXW0080` when base-revision bytes fail the strict
136/// reader, `BXW0081` when checked-in bytes fail the strict reader, or `BXW0082` when pairing or
137/// integrity fails. Integrity diagnostics are embedded unmodified; they never become report
138/// findings.
139pub fn classify_step(
140    packages: &[PackageSchemas],
141) -> Result<ContractClassificationCompletion, ClassifyStepError> {
142    if has_duplicate_package(packages) {
143        return Err(ClassifyStepError::Duplicate(DuplicatePackages));
144    }
145    let mut ordered: Vec<&PackageSchemas> = packages.iter().collect();
146    ordered.sort_by_key(|package| package.package.as_str());
147    let mut findings = Vec::new();
148    for package in ordered {
149        findings.extend(classify_package(package).map_err(ClassifyStepError::Classification)?);
150    }
151    Ok(match ClassificationFindings::new(findings) {
152        None => ContractClassificationCompletion::Passed,
153        Some(findings) => ContractClassificationCompletion::Failed(findings),
154    })
155}
156
157fn has_duplicate_package(packages: &[PackageSchemas]) -> bool {
158    let mut seen: Vec<&str> = packages
159        .iter()
160        .map(|package| package.package.as_str())
161        .collect();
162    seen.sort_unstable();
163    seen.windows(2).any(|pair| pair[0] == pair[1])
164}
165
166fn classify_package(
167    package: &PackageSchemas,
168) -> Result<Vec<ClassificationFinding>, CheckClassificationError> {
169    let package_id = package.package.clone();
170    let base = match package.base.as_deref() {
171        Some(bytes) => Some(SchemaDocument::parse(bytes).map_err(|diagnostics| {
172            CheckClassificationError::fail(package_id.clone(), CHECK_BASE, "base", diagnostics)
173        })?),
174        None => None,
175    };
176    let submitted = SchemaDocument::parse(&package.submitted).map_err(|diagnostics| {
177        CheckClassificationError::fail(
178            package_id.clone(),
179            CHECK_SUBMITTED,
180            "submitted",
181            diagnostics,
182        )
183    })?;
184    let report =
185        boxology_classifier::classify(base.as_ref(), Some(&submitted)).map_err(|diagnostics| {
186            CheckClassificationError::fail(
187                package_id.clone(),
188                CHECK_PAIRING,
189                "pairing",
190                diagnostics,
191            )
192        })?;
193    Ok(report
194        .findings()
195        .iter()
196        .map(|finding| {
197            ClassificationFinding::new(
198                package_id.clone(),
199                finding.path().to_owned(),
200                finding.code().to_owned(),
201                finding.class().canonical_name().to_owned(),
202                finding.condition().map(str::to_owned),
203            )
204        })
205        .collect())
206}