1use std::collections::BTreeMap;
5use std::fmt;
6
7use crate::provenance::Provenance;
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10
11#[derive(
13 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
14)]
15#[serde(rename_all = "snake_case")]
16pub enum Severity {
17 Info,
19 Warning,
21 Error,
23 Blocking,
25}
26
27impl fmt::Display for Severity {
28 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29 f.write_str(match self {
30 Self::Info => "info",
31 Self::Warning => "warning",
32 Self::Error => "error",
33 Self::Blocking => "blocking",
34 })
35 }
36}
37
38#[derive(
40 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
41)]
42#[serde(rename_all = "snake_case")]
43pub enum LossCategory {
44 Geometry,
46 Topology,
48 Material,
50 Metadata,
52 Units,
54 Attribute,
56 DesignIntent,
58 Other,
60}
61
62impl fmt::Display for LossCategory {
63 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64 f.write_str(match self {
65 Self::Geometry => "geometry",
66 Self::Topology => "topology",
67 Self::Material => "material",
68 Self::Metadata => "metadata",
69 Self::Units => "units",
70 Self::Attribute => "attribute",
71 Self::DesignIntent => "design_intent",
72 Self::Other => "other",
73 })
74 }
75}
76
77#[derive(
79 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
80)]
81#[serde(rename_all = "snake_case")]
82pub enum StrictConsequence {
83 Reject,
85 Tolerate,
87}
88
89#[derive(
91 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
92)]
93#[serde(rename_all = "snake_case")]
94#[non_exhaustive]
95pub enum LossCode {
96 ContainerOnly,
98 MissingGeometryStream,
101 TopologyNotTransferred,
104 GeometryNotTransferred,
107 ReferenceGraphNotClosed,
110 TopologyGaugeSubstituted,
113 CarrierAxisInferred,
116 CarrierSummary,
118 MaterialNotTransferred,
120 MetadataNotTransferred,
122 AttributesNotTransferred,
124 FeatureHistoryRetained,
127 AssemblyComponentsExternal,
130 RecordNotTyped,
132 DecodeDiagnostic,
135 MeshVertexPrecision,
138 ObjectRecordsUntransferred,
140 UnsupportedObjectFamily,
142 AssetNotTransferred,
144 NoExportableSolids,
147 HiddenBodyOmitted,
149 BodyTransformNotApplied,
152 AnalyticSurfaceNormalized,
155 EllipticalConeReduced,
157 CurvelessEdgeOmitted,
159 UnknownSurfaceFaceOmitted,
161 PcurveOmitted,
163 SubdOmitted,
166 TessellationOmitted,
168 PmiOmitted,
170 SourceAssociationOmitted,
172 PassthroughRecordOmitted,
174 ProceduralReduced,
177 ParametricRecordOmitted,
179 AppearanceReduced,
182}
183
184impl LossCode {
185 pub fn as_str(self) -> &'static str {
187 match self {
188 Self::ContainerOnly => "container_only",
189 Self::MissingGeometryStream => "missing_geometry_stream",
190 Self::TopologyNotTransferred => "topology_not_transferred",
191 Self::GeometryNotTransferred => "geometry_not_transferred",
192 Self::ReferenceGraphNotClosed => "reference_graph_not_closed",
193 Self::TopologyGaugeSubstituted => "topology_gauge_substituted",
194 Self::CarrierAxisInferred => "carrier_axis_inferred",
195 Self::CarrierSummary => "carrier_summary",
196 Self::MaterialNotTransferred => "material_not_transferred",
197 Self::MetadataNotTransferred => "metadata_not_transferred",
198 Self::AttributesNotTransferred => "attributes_not_transferred",
199 Self::FeatureHistoryRetained => "feature_history_retained",
200 Self::AssemblyComponentsExternal => "assembly_components_external",
201 Self::RecordNotTyped => "record_not_typed",
202 Self::DecodeDiagnostic => "decode_diagnostic",
203 Self::MeshVertexPrecision => "mesh_vertex_precision",
204 Self::ObjectRecordsUntransferred => "object_records_untransferred",
205 Self::UnsupportedObjectFamily => "unsupported_object_family",
206 Self::AssetNotTransferred => "asset_not_transferred",
207 Self::NoExportableSolids => "no_exportable_solids",
208 Self::HiddenBodyOmitted => "hidden_body_omitted",
209 Self::BodyTransformNotApplied => "body_transform_not_applied",
210 Self::AnalyticSurfaceNormalized => "analytic_surface_normalized",
211 Self::EllipticalConeReduced => "elliptical_cone_reduced",
212 Self::CurvelessEdgeOmitted => "curveless_edge_omitted",
213 Self::UnknownSurfaceFaceOmitted => "unknown_surface_face_omitted",
214 Self::PcurveOmitted => "pcurve_omitted",
215 Self::SubdOmitted => "subd_omitted",
216 Self::TessellationOmitted => "tessellation_omitted",
217 Self::PmiOmitted => "pmi_omitted",
218 Self::SourceAssociationOmitted => "source_association_omitted",
219 Self::PassthroughRecordOmitted => "passthrough_record_omitted",
220 Self::ProceduralReduced => "procedural_reduced",
221 Self::ParametricRecordOmitted => "parametric_record_omitted",
222 Self::AppearanceReduced => "appearance_reduced",
223 }
224 }
225
226 pub fn strict_consequence(self) -> StrictConsequence {
228 match self {
229 Self::MissingGeometryStream
230 | Self::TopologyNotTransferred
231 | Self::GeometryNotTransferred
232 | Self::ReferenceGraphNotClosed
233 | Self::CurvelessEdgeOmitted
234 | Self::UnknownSurfaceFaceOmitted
235 | Self::SubdOmitted
236 | Self::NoExportableSolids => StrictConsequence::Reject,
237 _ => StrictConsequence::Tolerate,
238 }
239 }
240}
241
242impl fmt::Display for LossCode {
243 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
244 f.write_str(self.as_str())
245 }
246}
247
248#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
250pub struct LossNote {
251 pub code: LossCode,
253 pub category: LossCategory,
255 pub severity: Severity,
257 pub message: String,
259 #[serde(default, skip_serializing_if = "Option::is_none")]
261 pub provenance: Option<Provenance>,
262}
263
264#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
266pub struct DecodeReport {
267 pub format: String,
269 pub container_only: bool,
271 pub geometry_transferred: bool,
273 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
275 pub coverage: BTreeMap<String, usize>,
276 pub losses: Vec<LossNote>,
278 pub notes: Vec<String>,
280}
281
282impl DecodeReport {
283 pub fn error_count(&self) -> usize {
285 self.losses
286 .iter()
287 .filter(|l| l.severity >= Severity::Error)
288 .count()
289 }
290}
291
292#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
294pub struct ExportReport {
295 pub format: String,
297 pub entity_counts: BTreeMap<String, usize>,
299 pub total_entities: usize,
301 pub losses: Vec<LossNote>,
303 pub notes: Vec<String>,
305}
306
307impl ExportReport {
308 pub fn error_count(&self) -> usize {
310 self.losses
311 .iter()
312 .filter(|loss| loss.severity >= Severity::Error)
313 .count()
314 }
315}
316
317#[derive(
319 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
320)]
321#[serde(rename_all = "snake_case")]
322pub enum Check {
323 Version,
325 Identity,
327 ProductStructure,
329 Pmi,
331 Presentation,
333 ArenaOrder,
335 ReferentialIntegrity,
337 LoopClosure,
339 CoedgePairing,
341 WireTopology,
343 ShellTopology,
345 CarrierReachability,
347 Annotations,
349 NativeLinks,
351 ParameterDomain,
353 Tolerances,
355 PayloadIntegrity,
357 Tessellation,
359 Units,
362 Bounds,
364 GeometricConsistency,
368 Counts,
370}
371
372impl fmt::Display for Check {
373 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
374 f.write_str(match self {
375 Self::Version => "version",
376 Self::Identity => "identity",
377 Self::ProductStructure => "product_structure",
378 Self::Pmi => "pmi",
379 Self::Presentation => "presentation",
380 Self::ArenaOrder => "arena_order",
381 Self::ReferentialIntegrity => "referential_integrity",
382 Self::LoopClosure => "loop_closure",
383 Self::CoedgePairing => "coedge_pairing",
384 Self::WireTopology => "wire_topology",
385 Self::ShellTopology => "shell_topology",
386 Self::CarrierReachability => "carrier_reachability",
387 Self::Annotations => "annotations",
388 Self::NativeLinks => "native_links",
389 Self::ParameterDomain => "parameter_domain",
390 Self::Tolerances => "tolerances",
391 Self::PayloadIntegrity => "payload_integrity",
392 Self::Tessellation => "tessellation",
393 Self::Units => "units",
394 Self::Bounds => "bounds",
395 Self::GeometricConsistency => "geometric_consistency",
396 Self::Counts => "counts",
397 })
398 }
399}
400
401#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
403pub struct Finding {
404 pub check: Check,
406 pub severity: Severity,
408 pub message: String,
410 #[serde(default, skip_serializing_if = "Option::is_none")]
412 pub entity: Option<String>,
413}
414
415#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
417pub struct ValidationReport {
418 pub entity_counts: BTreeMap<String, usize>,
420 pub findings: Vec<Finding>,
422 #[serde(default)]
424 pub losses: Vec<LossNote>,
425}
426
427impl ValidationReport {
428 pub fn error_count(&self) -> usize {
430 self.findings
431 .iter()
432 .filter(|f| f.severity >= Severity::Error)
433 .count()
434 }
435
436 pub fn warning_count(&self) -> usize {
438 self.findings
439 .iter()
440 .filter(|f| f.severity == Severity::Warning)
441 .count()
442 }
443
444 pub fn is_ok(&self) -> bool {
446 self.error_count() == 0
447 }
448}
449
450#[cfg(test)]
451#[allow(clippy::unwrap_used)]
452mod tests {
453 use super::*;
454
455 #[test]
456 fn loss_code_serializes_under_its_stable_identifier() {
457 let note = LossNote {
458 code: LossCode::TopologyNotTransferred,
459 category: LossCategory::Topology,
460 severity: Severity::Blocking,
461 message: "topology graph not transferred".to_owned(),
462 provenance: None,
463 };
464 let value: serde_json::Value = serde_json::to_value(¬e).expect("required invariant");
465 assert_eq!(value["code"], "topology_not_transferred");
466 assert_eq!(value["code"], LossCode::TopologyNotTransferred.as_str());
467 }
468
469 #[test]
470 fn loss_code_carries_reversibility_and_strict_consequence() {
471 assert_eq!(
472 LossCode::TopologyNotTransferred.strict_consequence(),
473 StrictConsequence::Reject
474 );
475 assert_eq!(
476 LossCode::PassthroughRecordOmitted.strict_consequence(),
477 StrictConsequence::Tolerate
478 );
479 }
480}