Skip to main content

boxology_classifier/
lib.rs

1//! A pure, fail-closed classifier for one schema revision against another.
2//!
3//! The classifier reads only the supplied [`SchemaDocument`] values. It consults no filesystem,
4//! environment, network, clock, locale, process, or execution state, and has no policy controls
5//! that could hide or relabel a finding. Named type-graph rows emit structured findings; every
6//! unmatched difference falls to the fail-closed default. Structural capability, metadata,
7//! named-field, and payload rows are named; reorder differences remain fail-closed.
8//! Canonical report renderings are available as [`render_text`] and [`render_json`].
9
10#![deny(missing_docs)]
11#![forbid(unsafe_code)]
12
13use boxology_schema::{
14    Diagnostic, Diagnostics, ExposureLevel, Idempotency, SchemaCapability, SchemaDataField,
15    SchemaDataShape, SchemaDataType, SchemaDataVariant, SchemaDocument, SchemaField, SchemaPayload,
16    SchemaType, SchemaVariant, TypeExpression,
17};
18use std::collections::BTreeMap;
19
20/// The compatibility class of one schema change, ordered from least to most severe.
21#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
22pub enum Class {
23    /// No compatibility-relevant change occurred.
24    Unchanged,
25    /// Only documentation changed.
26    Documentation,
27    /// Only deprecation metadata changed.
28    Deprecation,
29    /// New surface was introduced.
30    Additive,
31    /// A change is compatible only under a stated migration condition.
32    CompatibleWithConditions,
33    /// Existing surface was tightened or removed, or the change is unclassified.
34    Incompatible,
35}
36
37impl Class {
38    /// Returns the exact snake-case name of this class.
39    pub fn canonical_name(self) -> &'static str {
40        match self {
41            Self::Unchanged => "unchanged",
42            Self::Documentation => "documentation",
43            Self::Deprecation => "deprecation",
44            Self::Additive => "additive",
45            Self::CompatibleWithConditions => "compatible_with_conditions",
46            Self::Incompatible => "incompatible",
47        }
48    }
49}
50
51/// One classified schema change.
52#[derive(Debug, Eq, PartialEq)]
53pub struct Finding {
54    code: &'static str,
55    path: String,
56    kind: &'static str,
57    class: Class,
58    base_excerpt: Option<String>,
59    submitted_excerpt: Option<String>,
60    condition: Option<&'static str>,
61}
62
63impl Finding {
64    /// Returns the stable classifier code.
65    pub fn code(&self) -> &'static str {
66        self.code
67    }
68
69    /// Returns the canonical identity path of the change.
70    pub fn path(&self) -> &str {
71        &self.path
72    }
73
74    /// Returns the D5 change-kind name for this finding.
75    pub fn kind(&self) -> &'static str {
76        self.kind
77    }
78
79    /// Returns the compatibility class of the change.
80    pub fn class(&self) -> Class {
81        self.class
82    }
83
84    /// Returns the base-side compared-value excerpt, when present.
85    pub fn base_excerpt(&self) -> Option<&str> {
86        self.base_excerpt.as_deref()
87    }
88
89    /// Returns the submitted-side compared-value excerpt, when present.
90    pub fn submitted_excerpt(&self) -> Option<&str> {
91        self.submitted_excerpt.as_deref()
92    }
93
94    /// Returns the migration condition, when this finding is conditional.
95    pub fn condition(&self) -> Option<&'static str> {
96        self.condition
97    }
98}
99
100/// The findings and maximum-severity verdict for one classification.
101#[derive(Debug, Eq, PartialEq)]
102pub struct ClassificationReport {
103    findings: Vec<Finding>,
104    verdict: Class,
105}
106
107impl ClassificationReport {
108    /// Returns every finding in report order.
109    pub fn findings(&self) -> &[Finding] {
110        &self.findings
111    }
112
113    /// Returns the maximum class, or [`Class::Unchanged`] when there are no findings.
114    pub fn verdict(&self) -> Class {
115        self.verdict
116    }
117}
118
119/// Classifies the supplied base and submitted schema documents.
120pub fn classify(
121    base: Option<&SchemaDocument>,
122    submitted: Option<&SchemaDocument>,
123) -> Result<ClassificationReport, Diagnostics> {
124    match (base, submitted) {
125        (None, None) => {
126            Err(
127                Diagnostics::new(Vec::from([Diagnostic::classification_requires_document()]))
128                    .expect("one classification diagnostic"),
129            )
130        }
131        (None, Some(document)) => Ok(report(Vec::from([Finding {
132            code: "BXC0026",
133            path: document.box_id.as_str().to_owned(),
134            kind: KIND_CONTRACT_INTRODUCED,
135            class: Class::Additive,
136            base_excerpt: None,
137            submitted_excerpt: Some(document.box_id.as_str().to_owned()),
138            condition: None,
139        }]))),
140        (Some(document), None) => Ok(report(Vec::from([Finding {
141            code: "BXC0027",
142            path: document.box_id.as_str().to_owned(),
143            kind: KIND_CONTRACT_REMOVED,
144            class: Class::Incompatible,
145            base_excerpt: Some(document.box_id.as_str().to_owned()),
146            submitted_excerpt: None,
147            condition: None,
148        }]))),
149        (Some(base), Some(submitted)) if base.box_id != submitted.box_id => {
150            Err(Diagnostics::new(Vec::from([Diagnostic::box_id_mismatch()]))
151                .expect("one classification diagnostic"))
152        }
153        (Some(base), Some(submitted)) if equal_modulo_provenance(base, submitted) => {
154            Ok(report(Vec::new()))
155        }
156        // D6 check A: equal revisions with any remaining difference is an integrity error. Short-
157        // circuit rather than compute-then-check so an engine miss under equal revisions cannot
158        // go silent; fail-closedness under differing revisions guarantees detected differences
159        // would have yielded a finding.
160        (Some(base), Some(submitted)) if base.revision == submitted.revision => {
161            Err(Diagnostics::new(Vec::from([
162                Diagnostic::integrity_findings_under_equal_revisions(),
163            ]))
164            .expect("one integrity diagnostic"))
165        }
166        (Some(base), Some(submitted)) => {
167            let findings = classify_paired_documents(base, submitted);
168            // D6 check B: differing revisions with zero findings means the projection and the
169            // classifier disagree (for example a revision-only difference).
170            // Deviates from AC4's letter deliberately: an empty finding set is now Err(BXC0038)
171            // rather than an incompatible report carrying the unclassified-change code. D6 requires
172            // failing loudly when revision and findings disagree.
173            if findings.is_empty() {
174                Err(Diagnostics::new(Vec::from([
175                    Diagnostic::integrity_silence_under_differing_revisions(),
176                ]))
177                .expect("one integrity diagnostic"))
178            } else {
179                Ok(report(findings))
180            }
181        }
182    }
183}
184
185/// Fail-closed unclassified-change code (D5 default).
186const CODE_FAIL_CLOSED: &str = "BXC0028";
187
188/// Output-reachable type added (D5 additive row).
189const CODE_TYPE_ADDED: &str = "BXC0031";
190
191/// Type removed (D5 incompatible row).
192const CODE_TYPE_REMOVED: &str = "BXC0032";
193
194/// Documentation changed on any type-graph element (D5 documentation row).
195const CODE_DOCS_CHANGED: &str = "BXC0033";
196
197/// Deprecation metadata changed on any type-graph element (D5 deprecation row).
198const CODE_DEPRECATION_CHANGED: &str = "BXC0034";
199
200/// Variant removed (D5 incompatible row).
201const CODE_VARIANT_REMOVED: &str = "BXC0035";
202
203/// Referenced error-enum variant added (D5 conditional row).
204const CODE_VARIANT_ADDED: &str = "BXC0036";
205
206/// Capability added (D5 additive row).
207const CODE_CAPABILITY_ADDED: &str = "BXC0039";
208
209/// Capability removed (D5 incompatible row).
210const CODE_CAPABILITY_REMOVED: &str = "BXC0040";
211
212/// Capability input parameter name changed (D5 incompatible row).
213const CODE_INPUT_NAME_CHANGED: &str = "BXC0041";
214
215/// Capability input leaf type changed (D5 incompatible row).
216const CODE_INPUT_LEAF_CHANGED: &str = "BXC0042";
217
218/// Capability output leaf type changed (D5 incompatible row).
219const CODE_OUTPUT_LEAF_CHANGED: &str = "BXC0043";
220
221/// Capability declared error changed (D5 incompatible row).
222const CODE_ERROR_CHANGED: &str = "BXC0044";
223
224/// Capability exposure raised (D5 additive row).
225const CODE_EXPOSURE_RAISED: &str = "BXC0045";
226
227/// Capability exposure lowered (D5 incompatible row).
228const CODE_EXPOSURE_LOWERED: &str = "BXC0046";
229
230/// Capability idempotency strengthened (D5 additive row).
231const CODE_IDEMPOTENCY_STRENGTHENED: &str = "BXC0047";
232
233/// Capability idempotency weakened (D5 incompatible row).
234const CODE_IDEMPOTENCY_WEAKENED: &str = "BXC0048";
235
236/// Named payload field added (D5 role-sensitive row).
237const CODE_FIELD_ADDED: &str = "BXC0049";
238
239/// Named payload field removed (D5 incompatible row).
240const CODE_FIELD_REMOVED: &str = "BXC0050";
241
242/// Named payload field type changed (D5 incompatible row).
243const CODE_FIELD_TYPE_CHANGED: &str = "BXC0051";
244
245/// Error payload kind or value type changed (D5 incompatible row).
246const CODE_PAYLOAD_CHANGED: &str = "BXC0052";
247
248/// Structured type kind changed (D5 incompatible row).
249const CODE_TYPE_KIND_CHANGED: &str = "BXC0063";
250/// Output-reachable structured field added (D5 additive row).
251const CODE_OUTPUT_FIELD_ADDED: &str = "BXC0064";
252/// Output-reachable structured field removed (D5 incompatible row).
253const CODE_OUTPUT_FIELD_REMOVED: &str = "BXC0065";
254/// Output-reachable structured field type changed (D5 incompatible row).
255const CODE_OUTPUT_FIELD_TYPE_CHANGED: &str = "BXC0066";
256
257/// Input-reachable enum variant added (D5 conditional row).
258const CODE_INPUT_VARIANT_ADDED: &str = "BXC0067";
259
260/// Output-reachable enum variant added (D5 conditional row).
261const CODE_OUTPUT_VARIANT_ADDED: &str = "BXC0068";
262
263/// Output-reachable enum variant removed (D5 incompatible row).
264const CODE_OUTPUT_VARIANT_REMOVED: &str = "BXC0069";
265
266const KIND_CONTRACT_INTRODUCED: &str = "contract introduced";
267const KIND_CONTRACT_REMOVED: &str = "contract removed";
268const KIND_UNCLASSIFIED: &str = "unclassified change";
269const KIND_TYPE_ADDED: &str = "type added";
270const KIND_TYPE_REMOVED: &str = "type removed";
271const KIND_DOCS_CHANGED: &str = "documentation changed";
272const KIND_DEPRECATION_CHANGED: &str = "deprecation changed";
273const KIND_VARIANT_REMOVED: &str = "variant removed";
274const KIND_VARIANT_ADDED: &str = "error variant added";
275const KIND_CAPABILITY_ADDED: &str = "capability added";
276const KIND_CAPABILITY_REMOVED: &str = "capability removed";
277const KIND_INPUT_NAME_CHANGED: &str = "capability input parameter name changed";
278const KIND_INPUT_LEAF_CHANGED: &str = "capability input type changed";
279const KIND_OUTPUT_LEAF_CHANGED: &str = "capability output type changed";
280const KIND_ERROR_CHANGED: &str = "capability declared error changed";
281const KIND_EXPOSURE_RAISED: &str = "max exposure raised";
282const KIND_EXPOSURE_LOWERED: &str = "max exposure lowered";
283const KIND_IDEMPOTENCY_STRENGTHENED: &str = "idempotency strengthened";
284const KIND_IDEMPOTENCY_WEAKENED: &str = "idempotency weakened";
285const KIND_FIELD_ADDED: &str = "field added";
286const KIND_FIELD_REMOVED: &str = "field removed";
287const KIND_FIELD_TYPE_CHANGED: &str = "field type changed";
288const KIND_PAYLOAD_CHANGED: &str = "error payload changed";
289const KIND_TYPE_KIND_CHANGED: &str = "type kind changed";
290const KIND_ENUM_VARIANT_ADDED: &str = "enum variant added";
291
292/// Migration condition for referenced error-enum variant addition.
293const CONDITION_UNKNOWN_VARIANT: &str = "unknown-variant tolerance";
294/// Migration condition for an input addition that requires provider-first rollout.
295const CONDITION_PROVIDER_FIRST: &str = "provider-first deployment order";
296
297fn fail_closed_finding(base: &SchemaDocument) -> Finding {
298    Finding {
299        code: CODE_FAIL_CLOSED,
300        path: base.box_id.as_str().to_owned(),
301        kind: KIND_UNCLASSIFIED,
302        class: Class::Incompatible,
303        base_excerpt: None,
304        submitted_excerpt: None,
305        condition: None,
306    }
307}
308
309fn data_finding(
310    code: &'static str,
311    path: String,
312    kind: &'static str,
313    class: Class,
314    base_excerpt: Option<String>,
315    submitted_excerpt: Option<String>,
316    condition: Option<&'static str>,
317) -> Finding {
318    Finding {
319        code,
320        path,
321        kind,
322        class,
323        base_excerpt,
324        submitted_excerpt,
325        condition,
326    }
327}
328
329fn docs_excerpt(docs: &[String]) -> String {
330    docs.join("\n")
331}
332
333fn exposure_excerpt(level: ExposureLevel) -> &'static str {
334    match level {
335        ExposureLevel::CodeOnly => "code_only",
336        ExposureLevel::Internal => "internal",
337        ExposureLevel::External => "external",
338    }
339}
340
341fn idempotency_excerpt(value: Idempotency) -> &'static str {
342    match value {
343        Idempotency::None => "none",
344        Idempotency::Inherent => "inherent",
345    }
346}
347
348fn payload_kind_excerpt(payload: &SchemaPayload) -> &'static str {
349    match payload {
350        SchemaPayload::Unit => "unit",
351        SchemaPayload::Value { .. } => "value",
352        SchemaPayload::Named(_) => "named",
353    }
354}
355
356fn capability_named<'a>(document: &'a SchemaDocument, name: &str) -> Option<&'a SchemaCapability> {
357    document
358        .capabilities
359        .iter()
360        .find(|c| c.name.as_str() == name)
361}
362
363fn type_named<'a>(document: &'a SchemaDocument, name: &str) -> Option<&'a SchemaType> {
364    document.types.iter().find(|t| t.name == name)
365}
366
367fn variant_named<'a>(schema_type: &'a SchemaType, name: &str) -> Option<&'a SchemaVariant> {
368    schema_type.variants.iter().find(|v| v.name == name)
369}
370
371fn field_named<'a>(variant: &'a SchemaVariant, name: &str) -> Option<&'a SchemaField> {
372    match &variant.payload {
373        SchemaPayload::Named(fields) => fields.iter().find(|f| f.name == name),
374        _ => None,
375    }
376}
377
378fn data_type_named<'a>(document: &'a SchemaDocument, name: &str) -> Option<&'a SchemaDataType> {
379    document.data_types.iter().find(|item| item.name == name)
380}
381
382fn data_field_named<'a>(data_type: &'a SchemaDataType, name: &str) -> Option<&'a SchemaDataField> {
383    match &data_type.shape {
384        SchemaDataShape::Struct(fields) => fields.iter().find(|item| item.name == name),
385        SchemaDataShape::Enum(_) => None,
386    }
387}
388
389fn data_variant_named<'a>(
390    data_type: &'a SchemaDataType,
391    name: &str,
392) -> Option<&'a SchemaDataVariant> {
393    match &data_type.shape {
394        SchemaDataShape::Enum(variants) => variants.iter().find(|item| item.name == name),
395        SchemaDataShape::Struct(_) => None,
396    }
397}
398
399fn data_shape_excerpt(shape: &SchemaDataShape) -> &'static str {
400    match shape {
401        SchemaDataShape::Struct(_) => "struct",
402        SchemaDataShape::Enum(_) => "enum",
403    }
404}
405
406fn declaration<'a>(
407    document: &'a SchemaDocument,
408    name: &str,
409) -> Option<(&'static str, &'a [String], Option<&'a str>)> {
410    if let Some(item) = type_named(document, name) {
411        return Some(("error", &item.docs, item.deprecation.as_deref()));
412    }
413    data_type_named(document, name).map(|item| {
414        (
415            data_shape_excerpt(&item.shape),
416            item.docs.as_slice(),
417            item.deprecation.as_deref(),
418        )
419    })
420}
421
422fn is_top_level_optional(ty: &TypeExpression) -> bool {
423    let TypeExpression::Option(_) = ty else {
424        return false;
425    };
426    true
427}
428
429/// Applies the D5 type-graph and structural capability taxonomies, then the fail-closed default.
430///
431/// Named findings are always emitted individually. Unreferenced *additions* (type, variant, or
432/// field) fall to the fail-closed default per D5's preamble — a declared type reachable from no
433/// capability is not a named additive/conditional row. Documentation, deprecation, whole-type
434/// removals, and legacy error removals classify regardless of reachability; structured field and
435/// variant role rows are reachability-gated. Capability additions, removals, input-name changes,
436/// input-leaf changes, and output-leaf changes use their named rows. Capability metadata and named
437/// payload fields use their
438/// D5 rows; capability, type, variant, and field reorders remain fail closed at `<box>`. A
439/// revision-only difference (no type or capability delta) yields an empty finding list; `classify`
440/// turns that empty result into the D6 check-B integrity error.
441fn classify_paired_documents(base: &SchemaDocument, submitted: &SchemaDocument) -> Vec<Finding> {
442    let roles = reachability(base, submitted);
443    let changes = type_changes(base, submitted, &roles);
444    let mut findings = Vec::new();
445    let mut unclassified = append_data_findings(base, submitted, &roles, &mut findings);
446    for change in &changes {
447        match change {
448            TypeChange::TypeAdded { name, roles } if roles.output => {
449                findings.push(Finding {
450                    code: CODE_TYPE_ADDED,
451                    path: type_path(base, name),
452                    kind: KIND_TYPE_ADDED,
453                    class: Class::Additive,
454                    base_excerpt: None,
455                    submitted_excerpt: Some(name.clone()),
456                    condition: None,
457                });
458            }
459            TypeChange::TypeRemoved { name, .. } => {
460                findings.push(Finding {
461                    code: CODE_TYPE_REMOVED,
462                    path: type_path(base, name),
463                    kind: KIND_TYPE_REMOVED,
464                    class: Class::Incompatible,
465                    base_excerpt: Some(name.clone()),
466                    submitted_excerpt: None,
467                    condition: None,
468                });
469            }
470            TypeChange::TypeDocsChanged { name } => {
471                findings.push(Finding {
472                    code: CODE_DOCS_CHANGED,
473                    path: type_path(base, name),
474                    kind: KIND_DOCS_CHANGED,
475                    class: Class::Documentation,
476                    base_excerpt: type_named(base, name).map(|ty| docs_excerpt(&ty.docs)),
477                    submitted_excerpt: type_named(submitted, name).map(|ty| docs_excerpt(&ty.docs)),
478                    condition: None,
479                });
480            }
481            TypeChange::TypeDeprecationChanged { name } => {
482                findings.push(Finding {
483                    code: CODE_DEPRECATION_CHANGED,
484                    path: type_path(base, name),
485                    kind: KIND_DEPRECATION_CHANGED,
486                    class: Class::Deprecation,
487                    base_excerpt: type_named(base, name).and_then(|ty| ty.deprecation.clone()),
488                    submitted_excerpt: type_named(submitted, name)
489                        .and_then(|ty| ty.deprecation.clone()),
490                    condition: None,
491                });
492            }
493            TypeChange::VariantAdded {
494                type_name,
495                variant_name,
496                roles,
497            } if roles.output => {
498                findings.push(Finding {
499                    code: CODE_VARIANT_ADDED,
500                    path: variant_path(base, type_name, variant_name),
501                    kind: KIND_VARIANT_ADDED,
502                    class: Class::CompatibleWithConditions,
503                    base_excerpt: None,
504                    submitted_excerpt: Some(variant_name.clone()),
505                    condition: Some(CONDITION_UNKNOWN_VARIANT),
506                });
507            }
508            TypeChange::VariantRemoved {
509                type_name,
510                variant_name,
511                ..
512            } => {
513                findings.push(Finding {
514                    code: CODE_VARIANT_REMOVED,
515                    path: variant_path(base, type_name, variant_name),
516                    kind: KIND_VARIANT_REMOVED,
517                    class: Class::Incompatible,
518                    base_excerpt: Some(variant_name.clone()),
519                    submitted_excerpt: None,
520                    condition: None,
521                });
522            }
523            TypeChange::VariantDocsChanged {
524                type_name,
525                variant_name,
526            } => {
527                findings.push(Finding {
528                    code: CODE_DOCS_CHANGED,
529                    path: variant_path(base, type_name, variant_name),
530                    kind: KIND_DOCS_CHANGED,
531                    class: Class::Documentation,
532                    base_excerpt: type_named(base, type_name)
533                        .and_then(|ty| variant_named(ty, variant_name))
534                        .map(|v| docs_excerpt(&v.docs)),
535                    submitted_excerpt: type_named(submitted, type_name)
536                        .and_then(|ty| variant_named(ty, variant_name))
537                        .map(|v| docs_excerpt(&v.docs)),
538                    condition: None,
539                });
540            }
541            TypeChange::VariantDeprecationChanged {
542                type_name,
543                variant_name,
544            } => {
545                findings.push(Finding {
546                    code: CODE_DEPRECATION_CHANGED,
547                    path: variant_path(base, type_name, variant_name),
548                    kind: KIND_DEPRECATION_CHANGED,
549                    class: Class::Deprecation,
550                    base_excerpt: type_named(base, type_name)
551                        .and_then(|ty| variant_named(ty, variant_name))
552                        .and_then(|v| v.deprecation.clone()),
553                    submitted_excerpt: type_named(submitted, type_name)
554                        .and_then(|ty| variant_named(ty, variant_name))
555                        .and_then(|v| v.deprecation.clone()),
556                    condition: None,
557                });
558            }
559            TypeChange::PayloadDocsChanged {
560                type_name,
561                variant_name,
562            }
563            | TypeChange::FieldDocsChanged {
564                type_name,
565                variant_name,
566                field_name: _,
567            } => {
568                let (path, base_excerpt, submitted_excerpt) = match change {
569                    TypeChange::FieldDocsChanged { field_name, .. } => (
570                        field_path(base, type_name, variant_name, field_name),
571                        type_named(base, type_name)
572                            .and_then(|ty| variant_named(ty, variant_name))
573                            .and_then(|v| field_named(v, field_name))
574                            .map(|f| docs_excerpt(&f.docs)),
575                        type_named(submitted, type_name)
576                            .and_then(|ty| variant_named(ty, variant_name))
577                            .and_then(|v| field_named(v, field_name))
578                            .map(|f| docs_excerpt(&f.docs)),
579                    ),
580                    _ => (
581                        variant_path(base, type_name, variant_name),
582                        type_named(base, type_name)
583                            .and_then(|ty| variant_named(ty, variant_name))
584                            .map(|v| match &v.payload {
585                                SchemaPayload::Value { docs, .. } => docs_excerpt(docs),
586                                _ => docs_excerpt(&v.docs),
587                            }),
588                        type_named(submitted, type_name)
589                            .and_then(|ty| variant_named(ty, variant_name))
590                            .map(|v| match &v.payload {
591                                SchemaPayload::Value { docs, .. } => docs_excerpt(docs),
592                                _ => docs_excerpt(&v.docs),
593                            }),
594                    ),
595                };
596                findings.push(Finding {
597                    code: CODE_DOCS_CHANGED,
598                    path,
599                    kind: KIND_DOCS_CHANGED,
600                    class: Class::Documentation,
601                    base_excerpt,
602                    submitted_excerpt,
603                    condition: None,
604                });
605            }
606            TypeChange::PayloadDeprecationChanged {
607                type_name,
608                variant_name,
609            }
610            | TypeChange::FieldDeprecationChanged {
611                type_name,
612                variant_name,
613                field_name: _,
614            } => {
615                let (path, base_excerpt, submitted_excerpt) = match change {
616                    TypeChange::FieldDeprecationChanged { field_name, .. } => (
617                        field_path(base, type_name, variant_name, field_name),
618                        type_named(base, type_name)
619                            .and_then(|ty| variant_named(ty, variant_name))
620                            .and_then(|v| field_named(v, field_name))
621                            .and_then(|f| f.deprecation.clone()),
622                        type_named(submitted, type_name)
623                            .and_then(|ty| variant_named(ty, variant_name))
624                            .and_then(|v| field_named(v, field_name))
625                            .and_then(|f| f.deprecation.clone()),
626                    ),
627                    _ => (
628                        variant_path(base, type_name, variant_name),
629                        type_named(base, type_name)
630                            .and_then(|ty| variant_named(ty, variant_name))
631                            .and_then(|v| match &v.payload {
632                                SchemaPayload::Value { deprecation, .. } => deprecation.clone(),
633                                _ => v.deprecation.clone(),
634                            }),
635                        type_named(submitted, type_name)
636                            .and_then(|ty| variant_named(ty, variant_name))
637                            .and_then(|v| match &v.payload {
638                                SchemaPayload::Value { deprecation, .. } => deprecation.clone(),
639                                _ => v.deprecation.clone(),
640                            }),
641                    ),
642                };
643                findings.push(Finding {
644                    code: CODE_DEPRECATION_CHANGED,
645                    path,
646                    kind: KIND_DEPRECATION_CHANGED,
647                    class: Class::Deprecation,
648                    base_excerpt,
649                    submitted_excerpt,
650                    condition: None,
651                });
652            }
653            TypeChange::FieldAdded {
654                type_name,
655                variant_name,
656                field_name,
657                roles,
658            } if roles.input || roles.output => findings.push(Finding {
659                code: CODE_FIELD_ADDED,
660                path: field_path(base, type_name, variant_name, field_name),
661                kind: KIND_FIELD_ADDED,
662                class: if roles.input {
663                    Class::Incompatible
664                } else {
665                    Class::Additive
666                },
667                base_excerpt: None,
668                submitted_excerpt: Some(field_name.clone()),
669                condition: None,
670            }),
671            TypeChange::FieldRemoved {
672                type_name,
673                variant_name,
674                field_name,
675                ..
676            } => findings.push(Finding {
677                code: CODE_FIELD_REMOVED,
678                path: field_path(base, type_name, variant_name, field_name),
679                kind: KIND_FIELD_REMOVED,
680                class: Class::Incompatible,
681                base_excerpt: Some(field_name.clone()),
682                submitted_excerpt: None,
683                condition: None,
684            }),
685            TypeChange::FieldTypeChanged {
686                type_name,
687                variant_name,
688                field_name,
689            } => findings.push(Finding {
690                code: CODE_FIELD_TYPE_CHANGED,
691                path: field_path(base, type_name, variant_name, field_name),
692                kind: KIND_FIELD_TYPE_CHANGED,
693                class: Class::Incompatible,
694                base_excerpt: type_named(base, type_name)
695                    .and_then(|ty| variant_named(ty, variant_name))
696                    .and_then(|v| field_named(v, field_name))
697                    .map(|f| f.ty.canonical_name().to_owned()),
698                submitted_excerpt: type_named(submitted, type_name)
699                    .and_then(|ty| variant_named(ty, variant_name))
700                    .and_then(|v| field_named(v, field_name))
701                    .map(|f| f.ty.canonical_name().to_owned()),
702                condition: None,
703            }),
704            TypeChange::VariantPayloadChanged { type_name, .. }
705            | TypeChange::PayloadTypeChanged { type_name, .. } => {
706                let paths = error_paths(base, submitted, type_name);
707                if paths.is_empty() {
708                    unclassified = true;
709                }
710                let (base_excerpt, submitted_excerpt) = match change {
711                    TypeChange::VariantPayloadChanged { variant_name, .. } => (
712                        type_named(base, type_name)
713                            .and_then(|ty| variant_named(ty, variant_name))
714                            .map(|v| payload_kind_excerpt(&v.payload).to_owned()),
715                        type_named(submitted, type_name)
716                            .and_then(|ty| variant_named(ty, variant_name))
717                            .map(|v| payload_kind_excerpt(&v.payload).to_owned()),
718                    ),
719                    TypeChange::PayloadTypeChanged { variant_name, .. } => (
720                        type_named(base, type_name)
721                            .and_then(|ty| variant_named(ty, variant_name))
722                            .and_then(|v| match &v.payload {
723                                SchemaPayload::Value { ty, .. } => {
724                                    Some(ty.canonical_name().to_owned())
725                                }
726                                _ => None,
727                            }),
728                        type_named(submitted, type_name)
729                            .and_then(|ty| variant_named(ty, variant_name))
730                            .and_then(|v| match &v.payload {
731                                SchemaPayload::Value { ty, .. } => {
732                                    Some(ty.canonical_name().to_owned())
733                                }
734                                _ => None,
735                            }),
736                    ),
737                    _ => (None, None),
738                };
739                findings.extend(paths.into_iter().map(|path| Finding {
740                    code: CODE_PAYLOAD_CHANGED,
741                    path,
742                    kind: KIND_PAYLOAD_CHANGED,
743                    class: Class::Incompatible,
744                    base_excerpt: base_excerpt.clone(),
745                    submitted_excerpt: submitted_excerpt.clone(),
746                    condition: None,
747                }));
748            }
749            // Unreferenced type/variant/field additions and reorderings have no named row.
750            TypeChange::TypeAdded { .. }
751            | TypeChange::VariantAdded { .. }
752            | TypeChange::TypesReordered
753            | TypeChange::VariantsReordered { .. }
754            | TypeChange::FieldAdded { .. }
755            | TypeChange::FieldsReordered { .. } => {
756                unclassified = true;
757            }
758        }
759    }
760
761    let changes = capability_changes(base, submitted);
762    for change in &changes {
763        match change {
764            CapabilityChange::CapabilityAdded { name } => findings.push(Finding {
765                code: CODE_CAPABILITY_ADDED,
766                path: capability_path(base, name),
767                kind: KIND_CAPABILITY_ADDED,
768                class: Class::Additive,
769                base_excerpt: None,
770                submitted_excerpt: Some(name.clone()),
771                condition: None,
772            }),
773            CapabilityChange::CapabilityRemoved { name } => findings.push(Finding {
774                code: CODE_CAPABILITY_REMOVED,
775                path: capability_path(base, name),
776                kind: KIND_CAPABILITY_REMOVED,
777                class: Class::Incompatible,
778                base_excerpt: Some(name.clone()),
779                submitted_excerpt: None,
780                condition: None,
781            }),
782            CapabilityChange::InputNameChanged { name } => findings.push(Finding {
783                code: CODE_INPUT_NAME_CHANGED,
784                path: capability_input_path(base, name),
785                kind: KIND_INPUT_NAME_CHANGED,
786                class: Class::Incompatible,
787                base_excerpt: capability_named(base, name).map(|c| c.input.name.clone()),
788                submitted_excerpt: capability_named(submitted, name).map(|c| c.input.name.clone()),
789                condition: None,
790            }),
791            CapabilityChange::InputLeafChanged { name } => findings.push(Finding {
792                code: CODE_INPUT_LEAF_CHANGED,
793                path: capability_input_path(base, name),
794                kind: KIND_INPUT_LEAF_CHANGED,
795                class: Class::Incompatible,
796                base_excerpt: capability_named(base, name)
797                    .map(|c| c.input.leaf.canonical_name().to_owned()),
798                submitted_excerpt: capability_named(submitted, name)
799                    .map(|c| c.input.leaf.canonical_name().to_owned()),
800                condition: None,
801            }),
802            CapabilityChange::OutputLeafChanged { name } => findings.push(Finding {
803                code: CODE_OUTPUT_LEAF_CHANGED,
804                path: capability_output_path(base, name),
805                kind: KIND_OUTPUT_LEAF_CHANGED,
806                class: Class::Incompatible,
807                base_excerpt: capability_named(base, name)
808                    .map(|c| c.output.leaf.canonical_name().to_owned()),
809                submitted_excerpt: capability_named(submitted, name)
810                    .map(|c| c.output.leaf.canonical_name().to_owned()),
811                condition: None,
812            }),
813            CapabilityChange::CapabilityDocsChanged { name } => findings.push(Finding {
814                code: CODE_DOCS_CHANGED,
815                path: capability_path(base, name),
816                kind: KIND_DOCS_CHANGED,
817                class: Class::Documentation,
818                base_excerpt: capability_named(base, name).map(|c| docs_excerpt(&c.docs)),
819                submitted_excerpt: capability_named(submitted, name).map(|c| docs_excerpt(&c.docs)),
820                condition: None,
821            }),
822            CapabilityChange::CapabilityDeprecationChanged { name } => findings.push(Finding {
823                code: CODE_DEPRECATION_CHANGED,
824                path: capability_path(base, name),
825                kind: KIND_DEPRECATION_CHANGED,
826                class: Class::Deprecation,
827                base_excerpt: capability_named(base, name).and_then(|c| c.deprecation.clone()),
828                submitted_excerpt: capability_named(submitted, name)
829                    .and_then(|c| c.deprecation.clone()),
830                condition: None,
831            }),
832            CapabilityChange::CapabilityErrorChanged { name } => findings.push(Finding {
833                code: CODE_ERROR_CHANGED,
834                path: capability_suffix_path(base, name, "error"),
835                kind: KIND_ERROR_CHANGED,
836                class: Class::Incompatible,
837                base_excerpt: capability_named(base, name).map(|c| c.error.clone()),
838                submitted_excerpt: capability_named(submitted, name).map(|c| c.error.clone()),
839                condition: None,
840            }),
841            CapabilityChange::CapabilityExposureChanged {
842                name,
843                base: base_level,
844                submitted: submitted_level,
845            } => match exposure_classification(*base_level, *submitted_level) {
846                Some((code, class)) => findings.push(Finding {
847                    code,
848                    path: capability_suffix_path(base, name, "exposure"),
849                    kind: if code == CODE_EXPOSURE_RAISED {
850                        KIND_EXPOSURE_RAISED
851                    } else {
852                        KIND_EXPOSURE_LOWERED
853                    },
854                    class,
855                    base_excerpt: Some(exposure_excerpt(*base_level).to_owned()),
856                    submitted_excerpt: Some(exposure_excerpt(*submitted_level).to_owned()),
857                    condition: None,
858                }),
859                None => unclassified = true,
860            },
861            CapabilityChange::CapabilityIdempotencyChanged {
862                name,
863                base: base_property,
864                submitted: submitted_property,
865            } => match idempotency_classification(*base_property, *submitted_property) {
866                Some((code, class)) => findings.push(Finding {
867                    code,
868                    path: capability_suffix_path(base, name, "idempotency"),
869                    kind: if code == CODE_IDEMPOTENCY_STRENGTHENED {
870                        KIND_IDEMPOTENCY_STRENGTHENED
871                    } else {
872                        KIND_IDEMPOTENCY_WEAKENED
873                    },
874                    class,
875                    base_excerpt: Some(idempotency_excerpt(*base_property).to_owned()),
876                    submitted_excerpt: Some(idempotency_excerpt(*submitted_property).to_owned()),
877                    condition: None,
878                }),
879                None => unclassified = true,
880            },
881            CapabilityChange::CapabilitiesReordered => {
882                unclassified = true;
883            }
884        }
885    }
886
887    // Fail-closed default: unmatched type-graph kinds or capability reorderings emit one BXC0028
888    // at <box>. Every other capability difference has a named change above. An empty
889    // finding list (revision-only) is left empty for classify's check B.
890    if unclassified {
891        findings.push(fail_closed_finding(base));
892    }
893    findings
894}
895
896fn append_data_findings(
897    base: &SchemaDocument,
898    submitted: &SchemaDocument,
899    roles_by_type: &BTreeMap<&str, Roles>,
900    findings: &mut Vec<Finding>,
901) -> bool {
902    let mut unclassified = false;
903    for change in data_changes(base, submitted, roles_by_type) {
904        match change {
905            DataChange::TypeAdded { name, roles } if roles.input || roles.output => {
906                findings.push(data_finding(
907                    CODE_TYPE_ADDED,
908                    type_path(base, &name),
909                    KIND_TYPE_ADDED,
910                    Class::Additive,
911                    None,
912                    Some(name),
913                    None,
914                ));
915            }
916            DataChange::TypeRemoved { name, .. } => findings.push(data_finding(
917                CODE_TYPE_REMOVED,
918                type_path(base, &name),
919                KIND_TYPE_REMOVED,
920                Class::Incompatible,
921                Some(name),
922                None,
923                None,
924            )),
925            DataChange::TypeKindChanged(name) if roles_by_type.contains_key(name.as_str()) => {
926                findings.push(data_finding(
927                    CODE_TYPE_KIND_CHANGED,
928                    type_path(base, &name),
929                    KIND_TYPE_KIND_CHANGED,
930                    Class::Incompatible,
931                    declaration(base, &name).map(|item| item.0.to_owned()),
932                    declaration(submitted, &name).map(|item| item.0.to_owned()),
933                    None,
934                ))
935            }
936            DataChange::TypeDocsChanged { name } => findings.push(data_finding(
937                CODE_DOCS_CHANGED,
938                type_path(base, &name),
939                KIND_DOCS_CHANGED,
940                Class::Documentation,
941                declaration(base, &name).map(|item| docs_excerpt(item.1)),
942                declaration(submitted, &name).map(|item| docs_excerpt(item.1)),
943                None,
944            )),
945            DataChange::TypeDeprecationChanged { name } => findings.push(data_finding(
946                CODE_DEPRECATION_CHANGED,
947                type_path(base, &name),
948                KIND_DEPRECATION_CHANGED,
949                Class::Deprecation,
950                declaration(base, &name).and_then(|item| item.2.map(str::to_owned)),
951                declaration(submitted, &name).and_then(|item| item.2.map(str::to_owned)),
952                None,
953            )),
954            DataChange::FieldAdded {
955                type_name,
956                field_name,
957                roles,
958            } => {
959                if roles.input {
960                    let field = data_type_named(submitted, &type_name)
961                        .and_then(|item| data_field_named(item, &field_name))
962                        .expect("an added field is present in the submitted type");
963                    let optional = is_top_level_optional(&field.ty);
964                    findings.push(data_finding(
965                        CODE_FIELD_ADDED,
966                        data_field_path(base, &type_name, &field_name),
967                        KIND_FIELD_ADDED,
968                        if optional {
969                            Class::CompatibleWithConditions
970                        } else {
971                            Class::Incompatible
972                        },
973                        None,
974                        Some(field_name.clone()),
975                        optional.then_some(CONDITION_PROVIDER_FIRST),
976                    ));
977                }
978                if roles.output {
979                    findings.push(data_finding(
980                        CODE_OUTPUT_FIELD_ADDED,
981                        data_field_path(base, &type_name, &field_name),
982                        KIND_FIELD_ADDED,
983                        Class::Additive,
984                        None,
985                        Some(field_name),
986                        None,
987                    ));
988                }
989                unclassified |= !roles.input && !roles.output;
990            }
991            DataChange::FieldRemoved {
992                type_name,
993                field_name,
994                roles,
995            } => {
996                if roles.input {
997                    findings.push(data_finding(
998                        CODE_FIELD_REMOVED,
999                        data_field_path(base, &type_name, &field_name),
1000                        KIND_FIELD_REMOVED,
1001                        Class::Incompatible,
1002                        Some(field_name.clone()),
1003                        None,
1004                        None,
1005                    ));
1006                }
1007                if roles.output {
1008                    findings.push(data_finding(
1009                        CODE_OUTPUT_FIELD_REMOVED,
1010                        data_field_path(base, &type_name, &field_name),
1011                        KIND_FIELD_REMOVED,
1012                        Class::Incompatible,
1013                        Some(field_name),
1014                        None,
1015                        None,
1016                    ));
1017                }
1018                unclassified |= !roles.input && !roles.output;
1019            }
1020            DataChange::FieldTypeChanged {
1021                type_name,
1022                field_name,
1023            } => {
1024                let roles = roles_for(roles_by_type, &type_name);
1025                let path = data_field_path(base, &type_name, &field_name);
1026                let base_excerpt = data_type_named(base, &type_name)
1027                    .and_then(|item| data_field_named(item, &field_name))
1028                    .map(|item| item.ty.canonical_name());
1029                let submitted_excerpt = data_type_named(submitted, &type_name)
1030                    .and_then(|item| data_field_named(item, &field_name))
1031                    .map(|item| item.ty.canonical_name());
1032                if roles.input {
1033                    findings.push(data_finding(
1034                        CODE_FIELD_TYPE_CHANGED,
1035                        path.clone(),
1036                        KIND_FIELD_TYPE_CHANGED,
1037                        Class::Incompatible,
1038                        base_excerpt.clone(),
1039                        submitted_excerpt.clone(),
1040                        None,
1041                    ));
1042                }
1043                if roles.output {
1044                    findings.push(data_finding(
1045                        CODE_OUTPUT_FIELD_TYPE_CHANGED,
1046                        path,
1047                        KIND_FIELD_TYPE_CHANGED,
1048                        Class::Incompatible,
1049                        base_excerpt,
1050                        submitted_excerpt,
1051                        None,
1052                    ));
1053                }
1054                unclassified |= !roles.input && !roles.output;
1055            }
1056            DataChange::FieldDocsChanged {
1057                type_name,
1058                field_name,
1059            } => findings.push(data_finding(
1060                CODE_DOCS_CHANGED,
1061                data_field_path(base, &type_name, &field_name),
1062                KIND_DOCS_CHANGED,
1063                Class::Documentation,
1064                data_type_named(base, &type_name)
1065                    .and_then(|item| data_field_named(item, &field_name))
1066                    .map(|item| docs_excerpt(&item.docs)),
1067                data_type_named(submitted, &type_name)
1068                    .and_then(|item| data_field_named(item, &field_name))
1069                    .map(|item| docs_excerpt(&item.docs)),
1070                None,
1071            )),
1072            DataChange::FieldDeprecationChanged {
1073                type_name,
1074                field_name,
1075            } => findings.push(data_finding(
1076                CODE_DEPRECATION_CHANGED,
1077                data_field_path(base, &type_name, &field_name),
1078                KIND_DEPRECATION_CHANGED,
1079                Class::Deprecation,
1080                data_type_named(base, &type_name)
1081                    .and_then(|item| data_field_named(item, &field_name))
1082                    .and_then(|item| item.deprecation.clone()),
1083                data_type_named(submitted, &type_name)
1084                    .and_then(|item| data_field_named(item, &field_name))
1085                    .and_then(|item| item.deprecation.clone()),
1086                None,
1087            )),
1088            DataChange::VariantAdded {
1089                type_name,
1090                variant_name,
1091                roles,
1092            } => {
1093                if roles.input {
1094                    findings.push(data_finding(
1095                        CODE_INPUT_VARIANT_ADDED,
1096                        variant_path(base, &type_name, &variant_name),
1097                        KIND_ENUM_VARIANT_ADDED,
1098                        Class::CompatibleWithConditions,
1099                        None,
1100                        Some(variant_name.clone()),
1101                        Some(CONDITION_PROVIDER_FIRST),
1102                    ));
1103                }
1104                if roles.output {
1105                    findings.push(data_finding(
1106                        CODE_OUTPUT_VARIANT_ADDED,
1107                        variant_path(base, &type_name, &variant_name),
1108                        KIND_ENUM_VARIANT_ADDED,
1109                        Class::CompatibleWithConditions,
1110                        None,
1111                        Some(variant_name),
1112                        Some(CONDITION_UNKNOWN_VARIANT),
1113                    ));
1114                }
1115                unclassified |= !roles.input && !roles.output;
1116            }
1117            DataChange::VariantRemoved {
1118                type_name,
1119                variant_name,
1120                roles,
1121            } => {
1122                if roles.input {
1123                    findings.push(data_finding(
1124                        CODE_VARIANT_REMOVED,
1125                        variant_path(base, &type_name, &variant_name),
1126                        KIND_VARIANT_REMOVED,
1127                        Class::Incompatible,
1128                        Some(variant_name.clone()),
1129                        None,
1130                        None,
1131                    ));
1132                }
1133                if roles.output {
1134                    findings.push(data_finding(
1135                        CODE_OUTPUT_VARIANT_REMOVED,
1136                        variant_path(base, &type_name, &variant_name),
1137                        KIND_VARIANT_REMOVED,
1138                        Class::Incompatible,
1139                        Some(variant_name),
1140                        None,
1141                        None,
1142                    ));
1143                }
1144                unclassified |= !roles.input && !roles.output;
1145            }
1146            DataChange::VariantDocsChanged {
1147                type_name,
1148                variant_name,
1149            } => findings.push(data_finding(
1150                CODE_DOCS_CHANGED,
1151                variant_path(base, &type_name, &variant_name),
1152                KIND_DOCS_CHANGED,
1153                Class::Documentation,
1154                data_type_named(base, &type_name)
1155                    .and_then(|item| data_variant_named(item, &variant_name))
1156                    .map(|item| docs_excerpt(&item.docs)),
1157                data_type_named(submitted, &type_name)
1158                    .and_then(|item| data_variant_named(item, &variant_name))
1159                    .map(|item| docs_excerpt(&item.docs)),
1160                None,
1161            )),
1162            DataChange::VariantDeprecationChanged {
1163                type_name,
1164                variant_name,
1165            } => findings.push(data_finding(
1166                CODE_DEPRECATION_CHANGED,
1167                variant_path(base, &type_name, &variant_name),
1168                KIND_DEPRECATION_CHANGED,
1169                Class::Deprecation,
1170                data_type_named(base, &type_name)
1171                    .and_then(|item| data_variant_named(item, &variant_name))
1172                    .and_then(|item| item.deprecation.clone()),
1173                data_type_named(submitted, &type_name)
1174                    .and_then(|item| data_variant_named(item, &variant_name))
1175                    .and_then(|item| item.deprecation.clone()),
1176                None,
1177            )),
1178            DataChange::TypeAdded { .. }
1179            | DataChange::TypeKindChanged(_)
1180            | DataChange::TypesReordered
1181            | DataChange::FieldsReordered { .. }
1182            | DataChange::VariantsReordered { .. } => unclassified = true,
1183        }
1184    }
1185    unclassified
1186}
1187
1188fn exposure_classification(
1189    base: ExposureLevel,
1190    submitted: ExposureLevel,
1191) -> Option<(&'static str, Class)> {
1192    match (base, submitted) {
1193        (ExposureLevel::CodeOnly, ExposureLevel::Internal | ExposureLevel::External)
1194        | (ExposureLevel::Internal, ExposureLevel::External) => {
1195            Some((CODE_EXPOSURE_RAISED, Class::Additive))
1196        }
1197        (ExposureLevel::Internal | ExposureLevel::External, ExposureLevel::CodeOnly)
1198        | (ExposureLevel::External, ExposureLevel::Internal) => {
1199            Some((CODE_EXPOSURE_LOWERED, Class::Incompatible))
1200        }
1201        (ExposureLevel::CodeOnly, ExposureLevel::CodeOnly)
1202        | (ExposureLevel::Internal, ExposureLevel::Internal)
1203        | (ExposureLevel::External, ExposureLevel::External) => None,
1204    }
1205}
1206
1207fn idempotency_classification(
1208    base: Idempotency,
1209    submitted: Idempotency,
1210) -> Option<(&'static str, Class)> {
1211    match (base, submitted) {
1212        (Idempotency::None, Idempotency::Inherent) => {
1213            Some((CODE_IDEMPOTENCY_STRENGTHENED, Class::Additive))
1214        }
1215        (Idempotency::Inherent, Idempotency::None) => {
1216            Some((CODE_IDEMPOTENCY_WEAKENED, Class::Incompatible))
1217        }
1218        (Idempotency::None, Idempotency::None) | (Idempotency::Inherent, Idempotency::Inherent) => {
1219            None
1220        }
1221    }
1222}
1223
1224fn capability_path(base: &SchemaDocument, name: &str) -> String {
1225    [base.box_id.as_str(), ".", name].concat()
1226}
1227
1228fn capability_input_path(base: &SchemaDocument, name: &str) -> String {
1229    [capability_path(base, name).as_str(), "/input"].concat()
1230}
1231
1232fn capability_output_path(base: &SchemaDocument, name: &str) -> String {
1233    [capability_path(base, name).as_str(), "/output"].concat()
1234}
1235
1236fn capability_suffix_path(base: &SchemaDocument, name: &str, suffix: &str) -> String {
1237    [capability_path(base, name).as_str(), "/", suffix].concat()
1238}
1239
1240fn type_path(base: &SchemaDocument, name: &str) -> String {
1241    [base.box_id.as_str(), "/type/", name].concat()
1242}
1243
1244fn variant_path(base: &SchemaDocument, type_name: &str, variant_name: &str) -> String {
1245    [
1246        base.box_id.as_str(),
1247        "/type/",
1248        type_name,
1249        "/variant/",
1250        variant_name,
1251    ]
1252    .concat()
1253}
1254
1255fn data_field_path(base: &SchemaDocument, type_name: &str, field_name: &str) -> String {
1256    [type_path(base, type_name).as_str(), "/field/", field_name].concat()
1257}
1258
1259fn field_path(
1260    base: &SchemaDocument,
1261    type_name: &str,
1262    variant_name: &str,
1263    field_name: &str,
1264) -> String {
1265    [
1266        variant_path(base, type_name, variant_name).as_str(),
1267        "/field/",
1268        field_name,
1269    ]
1270    .concat()
1271}
1272
1273fn error_paths(base: &SchemaDocument, submitted: &SchemaDocument, type_name: &str) -> Vec<String> {
1274    let mut paths = Vec::new();
1275    for document in [base, submitted] {
1276        for capability in &document.capabilities {
1277            if capability.error == type_name {
1278                paths.push(capability_suffix_path(
1279                    base,
1280                    capability.name.as_str(),
1281                    "error",
1282                ));
1283            }
1284        }
1285    }
1286    paths.sort();
1287    paths.dedup();
1288    paths
1289}
1290
1291#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1292struct Roles {
1293    input: bool,
1294    output: bool,
1295}
1296
1297#[derive(Debug, Eq, PartialEq)]
1298enum DataChange {
1299    TypeAdded {
1300        name: String,
1301        roles: Roles,
1302    },
1303    TypeRemoved {
1304        name: String,
1305        roles: Roles,
1306    },
1307    TypesReordered,
1308    TypeKindChanged(String),
1309    TypeDocsChanged {
1310        name: String,
1311    },
1312    TypeDeprecationChanged {
1313        name: String,
1314    },
1315    FieldAdded {
1316        type_name: String,
1317        field_name: String,
1318        roles: Roles,
1319    },
1320    FieldRemoved {
1321        type_name: String,
1322        field_name: String,
1323        roles: Roles,
1324    },
1325    FieldsReordered {
1326        type_name: String,
1327    },
1328    FieldDocsChanged {
1329        type_name: String,
1330        field_name: String,
1331    },
1332    FieldDeprecationChanged {
1333        type_name: String,
1334        field_name: String,
1335    },
1336    FieldTypeChanged {
1337        type_name: String,
1338        field_name: String,
1339    },
1340    VariantAdded {
1341        type_name: String,
1342        variant_name: String,
1343        roles: Roles,
1344    },
1345    VariantRemoved {
1346        type_name: String,
1347        variant_name: String,
1348        roles: Roles,
1349    },
1350    VariantsReordered {
1351        type_name: String,
1352    },
1353    VariantDocsChanged {
1354        type_name: String,
1355        variant_name: String,
1356    },
1357    VariantDeprecationChanged {
1358        type_name: String,
1359        variant_name: String,
1360    },
1361}
1362
1363#[derive(Debug, Eq, PartialEq)]
1364enum TypeChange {
1365    TypeAdded {
1366        name: String,
1367        roles: Roles,
1368    },
1369    TypeRemoved {
1370        name: String,
1371        roles: Roles,
1372    },
1373    TypesReordered,
1374    TypeDocsChanged {
1375        name: String,
1376    },
1377    TypeDeprecationChanged {
1378        name: String,
1379    },
1380    VariantAdded {
1381        type_name: String,
1382        variant_name: String,
1383        roles: Roles,
1384    },
1385    VariantRemoved {
1386        type_name: String,
1387        variant_name: String,
1388        roles: Roles,
1389    },
1390    VariantsReordered {
1391        type_name: String,
1392    },
1393    VariantDocsChanged {
1394        type_name: String,
1395        variant_name: String,
1396    },
1397    VariantDeprecationChanged {
1398        type_name: String,
1399        variant_name: String,
1400    },
1401    VariantPayloadChanged {
1402        type_name: String,
1403        variant_name: String,
1404        roles: Roles,
1405    },
1406    PayloadDocsChanged {
1407        type_name: String,
1408        variant_name: String,
1409    },
1410    PayloadDeprecationChanged {
1411        type_name: String,
1412        variant_name: String,
1413    },
1414    PayloadTypeChanged {
1415        type_name: String,
1416        variant_name: String,
1417    },
1418    FieldAdded {
1419        type_name: String,
1420        variant_name: String,
1421        field_name: String,
1422        roles: Roles,
1423    },
1424    FieldRemoved {
1425        type_name: String,
1426        variant_name: String,
1427        field_name: String,
1428        roles: Roles,
1429    },
1430    FieldDocsChanged {
1431        type_name: String,
1432        variant_name: String,
1433        field_name: String,
1434    },
1435    FieldDeprecationChanged {
1436        type_name: String,
1437        variant_name: String,
1438        field_name: String,
1439    },
1440    FieldTypeChanged {
1441        type_name: String,
1442        variant_name: String,
1443        field_name: String,
1444    },
1445    FieldsReordered {
1446        type_name: String,
1447        variant_name: String,
1448    },
1449}
1450
1451#[derive(Debug, Eq, PartialEq)]
1452enum CapabilityChange {
1453    CapabilityAdded {
1454        name: String,
1455    },
1456    CapabilityRemoved {
1457        name: String,
1458    },
1459    CapabilitiesReordered,
1460    CapabilityDocsChanged {
1461        name: String,
1462    },
1463    CapabilityDeprecationChanged {
1464        name: String,
1465    },
1466    CapabilityErrorChanged {
1467        name: String,
1468    },
1469    CapabilityExposureChanged {
1470        name: String,
1471        base: ExposureLevel,
1472        submitted: ExposureLevel,
1473    },
1474    CapabilityIdempotencyChanged {
1475        name: String,
1476        base: Idempotency,
1477        submitted: Idempotency,
1478    },
1479    InputNameChanged {
1480        name: String,
1481    },
1482    InputLeafChanged {
1483        name: String,
1484    },
1485    OutputLeafChanged {
1486        name: String,
1487    },
1488}
1489
1490/// Reachability over the union of both documents' capability and local-type graphs.
1491///
1492/// Capability expressions seed their respective role, declared errors seed output, and struct
1493/// fields propagate both roles transitively through local, `Option`, and `Vec` expressions. The
1494/// monotone fixed point terminates even when individually acyclic documents form a cycle in their
1495/// union. Unit enums are leaves.
1496fn reachability<'a>(
1497    base: &'a SchemaDocument,
1498    submitted: &'a SchemaDocument,
1499) -> BTreeMap<&'a str, Roles> {
1500    let mut roles = BTreeMap::new();
1501    for document in [base, submitted] {
1502        for capability in &document.capabilities {
1503            mark_expression(
1504                &mut roles,
1505                &capability.input.leaf,
1506                Roles {
1507                    input: true,
1508                    output: false,
1509                },
1510            );
1511            mark_expression(
1512                &mut roles,
1513                &capability.output.leaf,
1514                Roles {
1515                    input: false,
1516                    output: true,
1517                },
1518            );
1519            mark_roles(
1520                &mut roles,
1521                capability.error.as_str(),
1522                Roles {
1523                    input: false,
1524                    output: true,
1525                },
1526            );
1527        }
1528    }
1529    loop {
1530        let mut changed = false;
1531        for document in [base, submitted] {
1532            for data_type in &document.data_types {
1533                let inherited = roles_for(&roles, data_type.name.as_str());
1534                if let SchemaDataShape::Struct(fields) = &data_type.shape {
1535                    for field in fields {
1536                        changed |= mark_expression(&mut roles, &field.ty, inherited);
1537                    }
1538                }
1539            }
1540        }
1541        if !changed {
1542            break;
1543        }
1544    }
1545    roles
1546}
1547
1548fn mark_expression<'a>(
1549    roles: &mut BTreeMap<&'a str, Roles>,
1550    expression: &'a TypeExpression,
1551    inherited: Roles,
1552) -> bool {
1553    if !inherited.input && !inherited.output {
1554        return false;
1555    }
1556    match expression {
1557        TypeExpression::Local(name) => mark_roles(roles, name, inherited),
1558        TypeExpression::Option(inner) | TypeExpression::Vec(inner) => {
1559            mark_expression(roles, inner, inherited)
1560        }
1561        _ => false,
1562    }
1563}
1564
1565fn mark_roles<'a>(roles: &mut BTreeMap<&'a str, Roles>, name: &'a str, inherited: Roles) -> bool {
1566    let entry = roles.entry(name).or_insert(Roles {
1567        input: false,
1568        output: false,
1569    });
1570    let before = *entry;
1571    entry.input |= inherited.input;
1572    entry.output |= inherited.output;
1573    *entry != before
1574}
1575
1576fn roles_for(roles: &BTreeMap<&str, Roles>, name: &str) -> Roles {
1577    roles.get(name).copied().unwrap_or(Roles {
1578        input: false,
1579        output: false,
1580    })
1581}
1582
1583fn index_types(types: &[SchemaType]) -> BTreeMap<&str, &SchemaType> {
1584    let mut index = BTreeMap::new();
1585    for schema_type in types {
1586        index.insert(schema_type.name.as_str(), schema_type);
1587    }
1588    index
1589}
1590
1591fn index_data_types(types: &[SchemaDataType]) -> BTreeMap<&str, &SchemaDataType> {
1592    types
1593        .iter()
1594        .map(|item| (item.name.as_str(), item))
1595        .collect()
1596}
1597
1598fn index_data_fields(fields: &[SchemaDataField]) -> BTreeMap<&str, &SchemaDataField> {
1599    fields
1600        .iter()
1601        .map(|item| (item.name.as_str(), item))
1602        .collect()
1603}
1604
1605fn index_data_variants(variants: &[SchemaDataVariant]) -> BTreeMap<&str, &SchemaDataVariant> {
1606    variants
1607        .iter()
1608        .map(|item| (item.name.as_str(), item))
1609        .collect()
1610}
1611
1612fn index_variants(variants: &[SchemaVariant]) -> BTreeMap<&str, &SchemaVariant> {
1613    let mut index = BTreeMap::new();
1614    for variant in variants {
1615        index.insert(variant.name.as_str(), variant);
1616    }
1617    index
1618}
1619
1620fn index_capabilities(capabilities: &[SchemaCapability]) -> BTreeMap<&str, &SchemaCapability> {
1621    let mut index = BTreeMap::new();
1622    for capability in capabilities {
1623        index.insert(capability.name.as_str(), capability);
1624    }
1625    index
1626}
1627
1628fn common_name_sequence_differs<'a, T, U>(
1629    base_names: impl Iterator<Item = &'a str>,
1630    submitted_names: impl Iterator<Item = &'a str>,
1631    base_index: &BTreeMap<&str, &T>,
1632    submitted_index: &BTreeMap<&str, &U>,
1633) -> bool {
1634    let mut base_common = Vec::new();
1635    for name in base_names {
1636        if submitted_index.contains_key(name) {
1637            base_common.push(name);
1638        }
1639    }
1640    let mut submitted_common = Vec::new();
1641    for name in submitted_names {
1642        if base_index.contains_key(name) {
1643            submitted_common.push(name);
1644        }
1645    }
1646    base_common != submitted_common
1647}
1648
1649fn append_cross_kind_changes(
1650    changes: &mut Vec<DataChange>,
1651    base: &SchemaDocument,
1652    submitted: &SchemaDocument,
1653    name: &str,
1654) {
1655    let (_, base_docs, base_deprecation) = declaration(base, name).expect("base declaration");
1656    let (_, submitted_docs, submitted_deprecation) =
1657        declaration(submitted, name).expect("submitted declaration");
1658    changes.push(DataChange::TypeKindChanged(name.to_owned()));
1659    if base_docs != submitted_docs {
1660        changes.push(DataChange::TypeDocsChanged {
1661            name: name.to_owned(),
1662        });
1663    }
1664    if base_deprecation != submitted_deprecation {
1665        changes.push(DataChange::TypeDeprecationChanged {
1666            name: name.to_owned(),
1667        });
1668    }
1669}
1670
1671fn data_changes(
1672    base: &SchemaDocument,
1673    submitted: &SchemaDocument,
1674    roles: &BTreeMap<&str, Roles>,
1675) -> Vec<DataChange> {
1676    let mut changes = Vec::new();
1677    let base_by_name = index_data_types(&base.data_types);
1678    let submitted_by_name = index_data_types(&submitted.data_types);
1679    for base_type in &base.data_types {
1680        match submitted_by_name.get(base_type.name.as_str()) {
1681            None if type_named(submitted, &base_type.name).is_some() => {
1682                append_cross_kind_changes(&mut changes, base, submitted, &base_type.name)
1683            }
1684            None => changes.push(DataChange::TypeRemoved {
1685                name: base_type.name.clone(),
1686                roles: roles_for(roles, base_type.name.as_str()),
1687            }),
1688            Some(submitted_type) => append_matched_data_type_changes(
1689                &mut changes,
1690                base_type,
1691                submitted_type,
1692                roles_for(roles, base_type.name.as_str()),
1693            ),
1694        }
1695    }
1696    for submitted_type in &submitted.data_types {
1697        if !base_by_name.contains_key(submitted_type.name.as_str()) {
1698            match type_named(base, &submitted_type.name) {
1699                Some(_) => {
1700                    append_cross_kind_changes(&mut changes, base, submitted, &submitted_type.name)
1701                }
1702                None => changes.push(DataChange::TypeAdded {
1703                    name: submitted_type.name.clone(),
1704                    roles: roles_for(roles, submitted_type.name.as_str()),
1705                }),
1706            }
1707        }
1708    }
1709    if common_name_sequence_differs(
1710        base.data_types.iter().map(|item| item.name.as_str()),
1711        submitted.data_types.iter().map(|item| item.name.as_str()),
1712        &base_by_name,
1713        &submitted_by_name,
1714    ) {
1715        changes.push(DataChange::TypesReordered);
1716    }
1717    changes
1718}
1719
1720fn append_matched_data_type_changes(
1721    changes: &mut Vec<DataChange>,
1722    base: &SchemaDataType,
1723    submitted: &SchemaDataType,
1724    roles: Roles,
1725) {
1726    if base.docs != submitted.docs {
1727        changes.push(DataChange::TypeDocsChanged {
1728            name: base.name.clone(),
1729        });
1730    }
1731    if base.deprecation != submitted.deprecation {
1732        changes.push(DataChange::TypeDeprecationChanged {
1733            name: base.name.clone(),
1734        });
1735    }
1736    match (&base.shape, &submitted.shape) {
1737        (SchemaDataShape::Struct(base_fields), SchemaDataShape::Struct(submitted_fields)) => {
1738            append_data_field_changes(
1739                changes,
1740                base.name.as_str(),
1741                base_fields,
1742                submitted_fields,
1743                roles,
1744            );
1745        }
1746        (SchemaDataShape::Enum(base_variants), SchemaDataShape::Enum(submitted_variants)) => {
1747            append_data_variant_changes(
1748                changes,
1749                base.name.as_str(),
1750                base_variants,
1751                submitted_variants,
1752                roles,
1753            );
1754        }
1755        _ => changes.push(DataChange::TypeKindChanged(base.name.clone())),
1756    }
1757}
1758
1759fn append_data_field_changes(
1760    changes: &mut Vec<DataChange>,
1761    type_name: &str,
1762    base: &[SchemaDataField],
1763    submitted: &[SchemaDataField],
1764    roles: Roles,
1765) {
1766    let base_by_name = index_data_fields(base);
1767    let submitted_by_name = index_data_fields(submitted);
1768    for field in base {
1769        match submitted_by_name.get(field.name.as_str()) {
1770            None => changes.push(DataChange::FieldRemoved {
1771                type_name: type_name.to_owned(),
1772                field_name: field.name.clone(),
1773                roles,
1774            }),
1775            Some(submitted_field) => {
1776                if field.docs != submitted_field.docs {
1777                    changes.push(DataChange::FieldDocsChanged {
1778                        type_name: type_name.to_owned(),
1779                        field_name: field.name.clone(),
1780                    });
1781                }
1782                if field.deprecation != submitted_field.deprecation {
1783                    changes.push(DataChange::FieldDeprecationChanged {
1784                        type_name: type_name.to_owned(),
1785                        field_name: field.name.clone(),
1786                    });
1787                }
1788                if field.ty != submitted_field.ty {
1789                    changes.push(DataChange::FieldTypeChanged {
1790                        type_name: type_name.to_owned(),
1791                        field_name: field.name.clone(),
1792                    });
1793                }
1794            }
1795        }
1796    }
1797    for field in submitted {
1798        if !base_by_name.contains_key(field.name.as_str()) {
1799            changes.push(DataChange::FieldAdded {
1800                type_name: type_name.to_owned(),
1801                field_name: field.name.clone(),
1802                roles,
1803            });
1804        }
1805    }
1806    if common_name_sequence_differs(
1807        base.iter().map(|item| item.name.as_str()),
1808        submitted.iter().map(|item| item.name.as_str()),
1809        &base_by_name,
1810        &submitted_by_name,
1811    ) {
1812        changes.push(DataChange::FieldsReordered {
1813            type_name: type_name.to_owned(),
1814        });
1815    }
1816}
1817
1818fn append_data_variant_changes(
1819    changes: &mut Vec<DataChange>,
1820    type_name: &str,
1821    base: &[SchemaDataVariant],
1822    submitted: &[SchemaDataVariant],
1823    roles: Roles,
1824) {
1825    let base_by_name = index_data_variants(base);
1826    let submitted_by_name = index_data_variants(submitted);
1827    for variant in base {
1828        match submitted_by_name.get(variant.name.as_str()) {
1829            None => changes.push(DataChange::VariantRemoved {
1830                type_name: type_name.to_owned(),
1831                variant_name: variant.name.clone(),
1832                roles,
1833            }),
1834            Some(submitted_variant) => {
1835                if variant.docs != submitted_variant.docs {
1836                    changes.push(DataChange::VariantDocsChanged {
1837                        type_name: type_name.to_owned(),
1838                        variant_name: variant.name.clone(),
1839                    });
1840                }
1841                if variant.deprecation != submitted_variant.deprecation {
1842                    changes.push(DataChange::VariantDeprecationChanged {
1843                        type_name: type_name.to_owned(),
1844                        variant_name: variant.name.clone(),
1845                    });
1846                }
1847            }
1848        }
1849    }
1850    for variant in submitted {
1851        if !base_by_name.contains_key(variant.name.as_str()) {
1852            changes.push(DataChange::VariantAdded {
1853                type_name: type_name.to_owned(),
1854                variant_name: variant.name.clone(),
1855                roles,
1856            });
1857        }
1858    }
1859    if common_name_sequence_differs(
1860        base.iter().map(|item| item.name.as_str()),
1861        submitted.iter().map(|item| item.name.as_str()),
1862        &base_by_name,
1863        &submitted_by_name,
1864    ) {
1865        changes.push(DataChange::VariantsReordered {
1866            type_name: type_name.to_owned(),
1867        });
1868    }
1869}
1870
1871fn type_changes(
1872    base: &SchemaDocument,
1873    submitted: &SchemaDocument,
1874    roles: &BTreeMap<&str, Roles>,
1875) -> Vec<TypeChange> {
1876    let mut changes = Vec::new();
1877    let base_by_name = index_types(&base.types);
1878    let submitted_by_name = index_types(&submitted.types);
1879
1880    for base_type in &base.types {
1881        match submitted_by_name.get(base_type.name.as_str()) {
1882            None if data_type_named(submitted, &base_type.name).is_some() => {}
1883            None => {
1884                changes.push(TypeChange::TypeRemoved {
1885                    name: base_type.name.clone(),
1886                    roles: roles_for(roles, base_type.name.as_str()),
1887                });
1888            }
1889            Some(submitted_type) => {
1890                append_matched_type_changes(
1891                    &mut changes,
1892                    base_type,
1893                    submitted_type,
1894                    roles_for(roles, base_type.name.as_str()),
1895                );
1896            }
1897        }
1898    }
1899
1900    for submitted_type in &submitted.types {
1901        if base_by_name.contains_key(submitted_type.name.as_str())
1902            || data_type_named(base, &submitted_type.name).is_some()
1903        {
1904            continue;
1905        }
1906        changes.push(TypeChange::TypeAdded {
1907            name: submitted_type.name.clone(),
1908            roles: roles_for(roles, submitted_type.name.as_str()),
1909        });
1910    }
1911
1912    if common_name_sequence_differs(
1913        base.types
1914            .iter()
1915            .map(|schema_type| schema_type.name.as_str()),
1916        submitted
1917            .types
1918            .iter()
1919            .map(|schema_type| schema_type.name.as_str()),
1920        &base_by_name,
1921        &submitted_by_name,
1922    ) {
1923        changes.push(TypeChange::TypesReordered);
1924    }
1925
1926    changes
1927}
1928
1929fn capability_changes(base: &SchemaDocument, submitted: &SchemaDocument) -> Vec<CapabilityChange> {
1930    let mut changes = Vec::new();
1931    let base_by_name = index_capabilities(&base.capabilities);
1932    let submitted_by_name = index_capabilities(&submitted.capabilities);
1933
1934    for base_capability in &base.capabilities {
1935        match submitted_by_name.get(base_capability.name.as_str()) {
1936            None => changes.push(CapabilityChange::CapabilityRemoved {
1937                name: base_capability.name.as_str().to_owned(),
1938            }),
1939            Some(submitted_capability) => append_matched_capability_changes(
1940                &mut changes,
1941                base_capability,
1942                submitted_capability,
1943            ),
1944        }
1945    }
1946
1947    for submitted_capability in &submitted.capabilities {
1948        if base_by_name.contains_key(submitted_capability.name.as_str()) {
1949            continue;
1950        }
1951        changes.push(CapabilityChange::CapabilityAdded {
1952            name: submitted_capability.name.as_str().to_owned(),
1953        });
1954    }
1955
1956    if common_name_sequence_differs(
1957        base.capabilities
1958            .iter()
1959            .map(|capability| capability.name.as_str()),
1960        submitted
1961            .capabilities
1962            .iter()
1963            .map(|capability| capability.name.as_str()),
1964        &base_by_name,
1965        &submitted_by_name,
1966    ) {
1967        changes.push(CapabilityChange::CapabilitiesReordered);
1968    }
1969
1970    changes
1971}
1972
1973fn append_matched_capability_changes(
1974    changes: &mut Vec<CapabilityChange>,
1975    base: &SchemaCapability,
1976    submitted: &SchemaCapability,
1977) {
1978    if base.input.leaf != submitted.input.leaf {
1979        changes.push(CapabilityChange::InputLeafChanged {
1980            name: base.name.as_str().to_owned(),
1981        });
1982    }
1983    if base.input.name != submitted.input.name {
1984        changes.push(CapabilityChange::InputNameChanged {
1985            name: base.name.as_str().to_owned(),
1986        });
1987    }
1988    if base.output.leaf != submitted.output.leaf {
1989        changes.push(CapabilityChange::OutputLeafChanged {
1990            name: base.name.as_str().to_owned(),
1991        });
1992    }
1993    if base.docs != submitted.docs {
1994        changes.push(CapabilityChange::CapabilityDocsChanged {
1995            name: base.name.as_str().to_owned(),
1996        });
1997    }
1998    if base.deprecation != submitted.deprecation {
1999        changes.push(CapabilityChange::CapabilityDeprecationChanged {
2000            name: base.name.as_str().to_owned(),
2001        });
2002    }
2003    if base.error != submitted.error {
2004        changes.push(CapabilityChange::CapabilityErrorChanged {
2005            name: base.name.as_str().to_owned(),
2006        });
2007    }
2008    if base.max_exposure != submitted.max_exposure {
2009        changes.push(CapabilityChange::CapabilityExposureChanged {
2010            name: base.name.as_str().to_owned(),
2011            base: base.max_exposure,
2012            submitted: submitted.max_exposure,
2013        });
2014    }
2015    if base.idempotency != submitted.idempotency {
2016        changes.push(CapabilityChange::CapabilityIdempotencyChanged {
2017            name: base.name.as_str().to_owned(),
2018            base: base.idempotency,
2019            submitted: submitted.idempotency,
2020        });
2021    }
2022}
2023
2024fn append_matched_type_changes(
2025    changes: &mut Vec<TypeChange>,
2026    base: &SchemaType,
2027    submitted: &SchemaType,
2028    roles: Roles,
2029) {
2030    if base.docs != submitted.docs {
2031        changes.push(TypeChange::TypeDocsChanged {
2032            name: base.name.clone(),
2033        });
2034    }
2035    if base.deprecation != submitted.deprecation {
2036        changes.push(TypeChange::TypeDeprecationChanged {
2037            name: base.name.clone(),
2038        });
2039    }
2040
2041    let base_by_name = index_variants(&base.variants);
2042    let submitted_by_name = index_variants(&submitted.variants);
2043
2044    for base_variant in &base.variants {
2045        match submitted_by_name.get(base_variant.name.as_str()) {
2046            None => {
2047                changes.push(TypeChange::VariantRemoved {
2048                    type_name: base.name.clone(),
2049                    variant_name: base_variant.name.clone(),
2050                    roles,
2051                });
2052            }
2053            Some(submitted_variant) => {
2054                append_matched_variant_changes(
2055                    changes,
2056                    base.name.as_str(),
2057                    base_variant,
2058                    submitted_variant,
2059                    roles,
2060                );
2061            }
2062        }
2063    }
2064
2065    for submitted_variant in &submitted.variants {
2066        if base_by_name.contains_key(submitted_variant.name.as_str()) {
2067            continue;
2068        }
2069        changes.push(TypeChange::VariantAdded {
2070            type_name: base.name.clone(),
2071            variant_name: submitted_variant.name.clone(),
2072            roles,
2073        });
2074    }
2075
2076    if common_name_sequence_differs(
2077        base.variants.iter().map(|variant| variant.name.as_str()),
2078        submitted
2079            .variants
2080            .iter()
2081            .map(|variant| variant.name.as_str()),
2082        &base_by_name,
2083        &submitted_by_name,
2084    ) {
2085        changes.push(TypeChange::VariantsReordered {
2086            type_name: base.name.clone(),
2087        });
2088    }
2089}
2090
2091fn append_matched_variant_changes(
2092    changes: &mut Vec<TypeChange>,
2093    type_name: &str,
2094    base: &SchemaVariant,
2095    submitted: &SchemaVariant,
2096    roles: Roles,
2097) {
2098    if base.docs != submitted.docs {
2099        changes.push(TypeChange::VariantDocsChanged {
2100            type_name: type_name.to_owned(),
2101            variant_name: base.name.clone(),
2102        });
2103    }
2104    if base.deprecation != submitted.deprecation {
2105        changes.push(TypeChange::VariantDeprecationChanged {
2106            type_name: type_name.to_owned(),
2107            variant_name: base.name.clone(),
2108        });
2109    }
2110    match (&base.payload, &submitted.payload) {
2111        (SchemaPayload::Named(base_fields), SchemaPayload::Named(submitted_fields)) => {
2112            append_named_field_changes(
2113                changes,
2114                type_name,
2115                base.name.as_str(),
2116                base_fields,
2117                submitted_fields,
2118                roles,
2119            );
2120        }
2121        (
2122            SchemaPayload::Value {
2123                docs: base_docs,
2124                deprecation: base_deprecation,
2125                ty: base_type,
2126            },
2127            SchemaPayload::Value {
2128                docs: submitted_docs,
2129                deprecation: submitted_deprecation,
2130                ty: submitted_type,
2131            },
2132        ) => {
2133            if base_docs != submitted_docs {
2134                changes.push(TypeChange::PayloadDocsChanged {
2135                    type_name: type_name.to_owned(),
2136                    variant_name: base.name.clone(),
2137                });
2138            }
2139            if base_deprecation != submitted_deprecation {
2140                changes.push(TypeChange::PayloadDeprecationChanged {
2141                    type_name: type_name.to_owned(),
2142                    variant_name: base.name.clone(),
2143                });
2144            }
2145            if base_type != submitted_type {
2146                changes.push(TypeChange::PayloadTypeChanged {
2147                    type_name: type_name.to_owned(),
2148                    variant_name: base.name.clone(),
2149                });
2150            }
2151        }
2152        (base_payload, submitted_payload) if base_payload != submitted_payload => {
2153            changes.push(TypeChange::VariantPayloadChanged {
2154                type_name: type_name.to_owned(),
2155                variant_name: base.name.clone(),
2156                roles,
2157            });
2158        }
2159        _ => {}
2160    }
2161}
2162
2163fn index_fields(fields: &[SchemaField]) -> BTreeMap<&str, &SchemaField> {
2164    let mut index = BTreeMap::new();
2165    for field in fields {
2166        index.insert(field.name.as_str(), field);
2167    }
2168    index
2169}
2170
2171fn append_named_field_changes(
2172    changes: &mut Vec<TypeChange>,
2173    type_name: &str,
2174    variant_name: &str,
2175    base_fields: &[SchemaField],
2176    submitted_fields: &[SchemaField],
2177    roles: Roles,
2178) {
2179    let base_by_name = index_fields(base_fields);
2180    let submitted_by_name = index_fields(submitted_fields);
2181
2182    for base_field in base_fields {
2183        match submitted_by_name.get(base_field.name.as_str()) {
2184            None => changes.push(TypeChange::FieldRemoved {
2185                type_name: type_name.to_owned(),
2186                variant_name: variant_name.to_owned(),
2187                field_name: base_field.name.clone(),
2188                roles,
2189            }),
2190            Some(submitted_field) => {
2191                if base_field.docs != submitted_field.docs {
2192                    changes.push(TypeChange::FieldDocsChanged {
2193                        type_name: type_name.to_owned(),
2194                        variant_name: variant_name.to_owned(),
2195                        field_name: base_field.name.clone(),
2196                    });
2197                }
2198                if base_field.deprecation != submitted_field.deprecation {
2199                    changes.push(TypeChange::FieldDeprecationChanged {
2200                        type_name: type_name.to_owned(),
2201                        variant_name: variant_name.to_owned(),
2202                        field_name: base_field.name.clone(),
2203                    });
2204                }
2205                if base_field.ty != submitted_field.ty {
2206                    changes.push(TypeChange::FieldTypeChanged {
2207                        type_name: type_name.to_owned(),
2208                        variant_name: variant_name.to_owned(),
2209                        field_name: base_field.name.clone(),
2210                    });
2211                }
2212            }
2213        }
2214    }
2215
2216    for submitted_field in submitted_fields {
2217        if !base_by_name.contains_key(submitted_field.name.as_str()) {
2218            changes.push(TypeChange::FieldAdded {
2219                type_name: type_name.to_owned(),
2220                variant_name: variant_name.to_owned(),
2221                field_name: submitted_field.name.clone(),
2222                roles,
2223            });
2224        }
2225    }
2226
2227    if common_name_sequence_differs(
2228        base_fields.iter().map(|field| field.name.as_str()),
2229        submitted_fields.iter().map(|field| field.name.as_str()),
2230        &base_by_name,
2231        &submitted_by_name,
2232    ) {
2233        changes.push(TypeChange::FieldsReordered {
2234            type_name: type_name.to_owned(),
2235            variant_name: variant_name.to_owned(),
2236        });
2237    }
2238}
2239
2240fn equal_modulo_provenance(base: &SchemaDocument, submitted: &SchemaDocument) -> bool {
2241    let SchemaDocument {
2242        box_id: base_box_id,
2243        capabilities: base_capabilities,
2244        data_types: base_data_types,
2245        provenance: _,
2246        revision: base_revision,
2247        types: base_types,
2248    } = base;
2249    let SchemaDocument {
2250        box_id: submitted_box_id,
2251        capabilities: submitted_capabilities,
2252        data_types: submitted_data_types,
2253        provenance: _,
2254        revision: submitted_revision,
2255        types: submitted_types,
2256    } = submitted;
2257    base_box_id == submitted_box_id
2258        && base_capabilities == submitted_capabilities
2259        && base_data_types == submitted_data_types
2260        && base_revision == submitted_revision
2261        && base_types == submitted_types
2262}
2263
2264fn report(findings: Vec<Finding>) -> ClassificationReport {
2265    let mut findings = findings;
2266    findings.sort_by(|left, right| {
2267        left.path
2268            .cmp(&right.path)
2269            .then_with(|| left.code.cmp(right.code))
2270    });
2271    let verdict = findings
2272        .iter()
2273        .map(|finding| finding.class)
2274        .max()
2275        .unwrap_or(Class::Unchanged);
2276    ClassificationReport { findings, verdict }
2277}
2278
2279mod report;
2280pub use report::{render_json, render_text};
2281
2282#[cfg(test)]
2283mod tests;