Skip to main content

excalidraw_render/
outcome.rs

1use std::collections::TryReserveError;
2use std::error::Error;
3use std::fmt;
4
5use serde::Serialize;
6
7use crate::diagnostic_registry::DiagnosticKind;
8use crate::{
9    DiagnosticDetails, ErrorCode, ErrorDescriptor, OutputFormat, ReferenceProfileIdentity,
10};
11
12/// String-backed stable compatibility diagnostic code.
13#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize)]
14#[serde(transparent)]
15pub struct DiagnosticCode(&'static str);
16
17impl DiagnosticCode {
18    /// A missing source element ID was repaired.
19    pub const NORMALIZATION_MISSING_ELEMENT_ID: Self = Self("normalization.missing-element-id");
20    /// A later duplicate source element ID was repaired.
21    pub const NORMALIZATION_DUPLICATE_ELEMENT_ID: Self = Self("normalization.duplicate-element-id");
22    /// A missing required timestamp was repaired to canonical zero.
23    pub const NORMALIZATION_MISSING_TIMESTAMP: Self = Self("normalization.missing-timestamp");
24    /// A zero Rough seed was repaired deterministically.
25    pub const NORMALIZATION_ZERO_ROUGH_SEED: Self = Self("normalization.zero-rough-seed");
26    /// A cycle-closing relationship edge was removed.
27    pub const NORMALIZATION_RELATIONSHIP_CYCLE: Self = Self("normalization.relationship-cycle");
28    /// An unknown element type used the compatibility fallback.
29    pub const COMPATIBILITY_UNSUPPORTED_ELEMENT_TYPE: Self =
30        Self("compatibility.unsupported-element-type");
31    /// A malformed element was replaced or omitted.
32    pub const COMPATIBILITY_MALFORMED_ELEMENT: Self = Self("compatibility.malformed-element");
33    /// An unsupported property was ignored.
34    pub const COMPATIBILITY_UNSUPPORTED_PROPERTY: Self = Self("compatibility.unsupported-property");
35    /// An unsupported property value used its declared recovery.
36    pub const COMPATIBILITY_UNSUPPORTED_PROPERTY_VALUE: Self =
37        Self("compatibility.unsupported-property-value");
38    /// A known but unavailable element was replaced or omitted.
39    pub const COMPATIBILITY_KNOWN_ELEMENT_REPLACED: Self =
40        Self("compatibility.known-element-replaced");
41    /// A scene-embedded asset remained authoritative over a supplement.
42    pub const ASSET_SUPPLEMENT_CONFLICT: Self = Self("asset.supplement-conflict");
43    /// Sniffed payload type replaced a mismatched advisory media type.
44    pub const ASSET_MEDIA_TYPE_MISMATCH: Self = Self("asset.media-type-mismatch");
45    /// A referenced scene file record was malformed.
46    pub const ASSET_MALFORMED_FILE_RECORD: Self = Self("asset.malformed-file-record");
47    /// A referring image had no authoritative file payload.
48    pub const IMAGE_MISSING_FILE: Self = Self("image.missing-file");
49    /// A forbidden image resource was removed or replaced.
50    pub const IMAGE_FORBIDDEN_RESOURCE: Self = Self("image.forbidden-resource");
51    /// A rendered animated image occurrence was flattened.
52    pub const IMAGE_ANIMATION_FLATTENED: Self = Self("image.animation-flattened");
53    /// An unsupported image format was omitted or replaced.
54    pub const IMAGE_UNSUPPORTED_FORMAT: Self = Self("image.unsupported-format");
55    /// Image decoding failed and used format-specific recovery.
56    pub const IMAGE_DECODE_FAILED: Self = Self("image.decode-failed");
57    /// Unsafe nested SVG content was removed.
58    pub const IMAGE_UNSAFE_SVG_CONTENT_REMOVED: Self = Self("image.unsafe-svg-content-removed");
59    /// An unsafe element link was omitted.
60    pub const LINK_UNSAFE_OMITTED: Self = Self("link.unsafe-omitted");
61    /// An invalid color was substituted deterministically.
62    pub const COLOR_INVALID: Self = Self("color.invalid");
63    /// A local font request used bundled Liberation Sans.
64    pub const FONT_LIBERATION_SANS_SUBSTITUTED: Self = Self("font.liberation-sans-substituted");
65    /// Emoji text used bundled Noto Emoji.
66    pub const FONT_NOTO_EMOJI_SUBSTITUTED: Self = Self("font.noto-emoji-substituted");
67    /// A missing glyph was replaced deterministically.
68    pub const FONT_MISSING_GLYPH: Self = Self("font.missing-glyph");
69    /// An over-complex element was replaced or omitted.
70    pub const RESOURCE_ELEMENT_COMPLEXITY_REPLACED: Self =
71        Self("resource.element-complexity-replaced");
72    /// Element-local semantic work used local recovery.
73    pub const RESOURCE_ELEMENT_WORK_EXCEEDED: Self = Self("resource.element-work-exceeded");
74    /// One asset occurrence exceeded its byte ceiling.
75    pub const RESOURCE_ASSET_BYTES_EXCEEDED: Self = Self("resource.asset-bytes-exceeded");
76    /// One semantic asset exceeded its work ceiling.
77    pub const RESOURCE_ASSET_WORK_EXCEEDED: Self = Self("resource.asset-work-exceeded");
78    /// Raster dimensions exceeded the local image ceiling.
79    pub const RESOURCE_IMAGE_DIMENSIONS_EXCEEDED: Self = Self("resource.image-dimensions-exceeded");
80    /// Decoded image pixels exceeded the local asset ceiling.
81    pub const RESOURCE_IMAGE_PIXELS_EXCEEDED: Self = Self("resource.image-pixels-exceeded");
82    /// Nested SVG complexity exceeded its local node ceiling.
83    pub const RESOURCE_NESTED_SVG_COMPLEXITY_EXCEEDED: Self =
84        Self("resource.nested-svg-complexity-exceeded");
85    /// Nested asset discovery exceeded its local depth ceiling.
86    pub const RESOURCE_NESTED_ASSET_DEPTH_EXCEEDED: Self =
87        Self("resource.nested-asset-depth-exceeded");
88    /// A coordinate exceeded its local magnitude ceiling.
89    pub const RESOURCE_COORDINATE_RANGE_EXCEEDED: Self = Self("resource.coordinate-range-exceeded");
90    /// Optional metadata was omitted after a resource crossing.
91    pub const RESOURCE_OPTIONAL_METADATA_OMITTED: Self = Self("resource.optional-metadata-omitted");
92    /// Provenance serialization failed and was omitted.
93    pub const METADATA_PROVENANCE_SERIALIZATION_FAILED: Self =
94        Self("metadata.provenance-serialization-failed");
95    /// Diagnostic-summary serialization failed and was omitted.
96    pub const METADATA_DIAGNOSTIC_SUMMARY_SERIALIZATION_FAILED: Self =
97        Self("metadata.diagnostic-summary-serialization-failed");
98    /// Embedded-scene serialization failed and was omitted.
99    pub const METADATA_SCENE_SERIALIZATION_FAILED: Self =
100        Self("metadata.scene-serialization-failed");
101    /// Embedded-scene compression failed and was omitted.
102    pub const METADATA_SCENE_COMPRESSION_FAILED: Self = Self("metadata.scene-compression-failed");
103    /// Embedded-scene carrier insertion failed and was omitted.
104    pub const METADATA_SCENE_EMBEDDING_FAILED: Self = Self("metadata.scene-embedding-failed");
105    /// Artifact-manifest serialization failed.
106    pub const METADATA_MANIFEST_SERIALIZATION_FAILED: Self =
107        Self("metadata.manifest-serialization-failed");
108    /// Artifact-manifest carrier insertion failed.
109    pub const METADATA_MANIFEST_EMBEDDING_FAILED: Self = Self("metadata.manifest-embedding-failed");
110    /// A permitted isolated PDF closure was rasterized.
111    pub const PDF_RASTER_FALLBACK: Self = Self("pdf.raster-fallback");
112
113    /// The complete revision-1 registry (spec section 11.3), in the
114    /// manifest's order. The registry tests check it against the manifest's
115    /// registry mappings.
116    pub(crate) const ALL: [Self; 42] = [
117        Self::NORMALIZATION_MISSING_ELEMENT_ID,
118        Self::NORMALIZATION_DUPLICATE_ELEMENT_ID,
119        Self::NORMALIZATION_MISSING_TIMESTAMP,
120        Self::NORMALIZATION_ZERO_ROUGH_SEED,
121        Self::NORMALIZATION_RELATIONSHIP_CYCLE,
122        Self::COMPATIBILITY_UNSUPPORTED_ELEMENT_TYPE,
123        Self::COMPATIBILITY_MALFORMED_ELEMENT,
124        Self::COMPATIBILITY_UNSUPPORTED_PROPERTY,
125        Self::COMPATIBILITY_UNSUPPORTED_PROPERTY_VALUE,
126        Self::COMPATIBILITY_KNOWN_ELEMENT_REPLACED,
127        Self::ASSET_SUPPLEMENT_CONFLICT,
128        Self::ASSET_MEDIA_TYPE_MISMATCH,
129        Self::ASSET_MALFORMED_FILE_RECORD,
130        Self::IMAGE_MISSING_FILE,
131        Self::IMAGE_FORBIDDEN_RESOURCE,
132        Self::IMAGE_ANIMATION_FLATTENED,
133        Self::IMAGE_UNSUPPORTED_FORMAT,
134        Self::IMAGE_DECODE_FAILED,
135        Self::IMAGE_UNSAFE_SVG_CONTENT_REMOVED,
136        Self::LINK_UNSAFE_OMITTED,
137        Self::COLOR_INVALID,
138        Self::FONT_LIBERATION_SANS_SUBSTITUTED,
139        Self::FONT_NOTO_EMOJI_SUBSTITUTED,
140        Self::FONT_MISSING_GLYPH,
141        Self::RESOURCE_ELEMENT_COMPLEXITY_REPLACED,
142        Self::RESOURCE_ELEMENT_WORK_EXCEEDED,
143        Self::RESOURCE_ASSET_BYTES_EXCEEDED,
144        Self::RESOURCE_ASSET_WORK_EXCEEDED,
145        Self::RESOURCE_IMAGE_DIMENSIONS_EXCEEDED,
146        Self::RESOURCE_IMAGE_PIXELS_EXCEEDED,
147        Self::RESOURCE_NESTED_SVG_COMPLEXITY_EXCEEDED,
148        Self::RESOURCE_NESTED_ASSET_DEPTH_EXCEEDED,
149        Self::RESOURCE_COORDINATE_RANGE_EXCEEDED,
150        Self::RESOURCE_OPTIONAL_METADATA_OMITTED,
151        Self::METADATA_PROVENANCE_SERIALIZATION_FAILED,
152        Self::METADATA_DIAGNOSTIC_SUMMARY_SERIALIZATION_FAILED,
153        Self::METADATA_SCENE_SERIALIZATION_FAILED,
154        Self::METADATA_SCENE_COMPRESSION_FAILED,
155        Self::METADATA_SCENE_EMBEDDING_FAILED,
156        Self::METADATA_MANIFEST_SERIALIZATION_FAILED,
157        Self::METADATA_MANIFEST_EMBEDDING_FAILED,
158        Self::PDF_RASTER_FALLBACK,
159    ];
160
161    /// The registered code with the stable spelling `code`, or `None` when
162    /// the registry has no such code. The sealed scene-surface policy names
163    /// its codes as strings; this is how they become codes, so a spelling the
164    /// registry does not know cannot be reported.
165    pub(crate) fn from_stable_str(code: &str) -> Option<Self> {
166        Self::ALL.into_iter().find(|known| known.as_str() == code)
167    }
168
169    /// Returns the stable serialized spelling.
170    pub const fn as_str(self) -> &'static str {
171        self.0
172    }
173}
174
175impl fmt::Display for DiagnosticCode {
176    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
177        formatter.write_str(self.as_str())
178    }
179}
180
181/// Severity of one compatibility diagnostic occurrence.
182///
183/// Severities are ordered from least to most severe: `Info < Warning`.
184#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
185#[non_exhaustive]
186#[serde(rename_all = "snake_case")]
187pub enum DiagnosticSeverity {
188    /// Informational compatibility behavior.
189    Info,
190    /// Recoverable compatibility loss or repair.
191    Warning,
192}
193
194impl DiagnosticSeverity {
195    /// Returns the stable serialized spelling.
196    pub const fn as_str(self) -> &'static str {
197        match self {
198            Self::Info => "info",
199            Self::Warning => "warning",
200        }
201    }
202}
203
204impl fmt::Display for DiagnosticSeverity {
205    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
206        formatter.write_str(self.as_str())
207    }
208}
209
210/// Observable effect class of a compatibility diagnostic.
211#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize)]
212#[non_exhaustive]
213#[serde(rename_all = "snake_case")]
214pub enum DiagnosticEffect {
215    /// Submitted input was repaired, selected, or ignored.
216    InputRepair,
217    /// Appearance may differ.
218    Visual,
219    /// Links, text, animation, or vector semantics may differ.
220    Semantic,
221    /// Carried metadata may differ.
222    Metadata,
223}
224
225impl DiagnosticEffect {
226    /// Returns the stable serialized spelling.
227    pub const fn as_str(self) -> &'static str {
228        match self {
229            Self::InputRepair => "input_repair",
230            Self::Visual => "visual",
231            Self::Semantic => "semantic",
232            Self::Metadata => "metadata",
233        }
234    }
235
236    /// The effect the scene-surface manifest spells `effect`, if any.
237    pub(crate) fn from_manifest(effect: &str) -> Option<Self> {
238        match effect {
239            "input_repair" => Some(Self::InputRepair),
240            "visual" => Some(Self::Visual),
241            "semantic" => Some(Self::Semantic),
242            "metadata" => Some(Self::Metadata),
243            _ => None,
244        }
245    }
246}
247
248impl fmt::Display for DiagnosticEffect {
249    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
250        formatter.write_str(self.as_str())
251    }
252}
253
254/// One compatibility diagnostic as the stage that diagnosed it emits it:
255/// everything the report carries except the position the session assigns
256/// when it retains it. Built only from a [`DiagnosticKind`], so the code,
257/// severity and details agree by construction, and turned into a
258/// [`CompatibilityDiagnostic`] only by [`PendingDiagnostic::retained`], so a
259/// diagnostic in a report always has a real order.
260#[derive(Clone, Debug, PartialEq)]
261pub(crate) struct PendingDiagnostic {
262    code: DiagnosticCode,
263    severity: DiagnosticSeverity,
264    format: OutputFormat,
265    effects: Vec<DiagnosticEffect>,
266    element_id: Option<String>,
267    json_pointer: String,
268    message: String,
269    details: Option<Box<DiagnosticDetails>>,
270}
271
272fn set_identity_effects(
273    effects: &mut Vec<DiagnosticEffect>,
274    format: OutputFormat,
275    visual: bool,
276) -> Result<(), TryReserveError> {
277    effects.try_reserve(3)?;
278    effects.clear();
279    effects.push(DiagnosticEffect::InputRepair);
280    if visual {
281        effects.push(DiagnosticEffect::Visual);
282        if format != OutputFormat::Png {
283            effects.push(DiagnosticEffect::Semantic);
284        }
285    }
286    Ok(())
287}
288
289impl PendingDiagnostic {
290    pub(crate) fn omit_unbounded_known_element_fallback(&mut self) -> Result<(), TryReserveError> {
291        if self.code != DiagnosticCode::COMPATIBILITY_UNSUPPORTED_PROPERTY_VALUE {
292            return Ok(());
293        }
294        if let Some(details) = self.details.as_deref_mut() {
295            if matches!(details, DiagnosticDetails::Compatibility { recovery:crate::Recovery::Placeholder, recipe_id:Some(recipe) }
296                if recipe==crate::diagnostic_registry::FallbackRecipe::KnownElementUnavailable.id())
297            {
298                *details = DiagnosticKind::UnsupportedPropertyValue {
299                    recovery: crate::Recovery::Omitted,
300                    recipe: None,
301                }
302                .into_details()?;
303            }
304        }
305        Ok(())
306    }
307    pub(crate) fn set_identity_effects(&mut self, visual: bool) -> Result<(), TryReserveError> {
308        set_identity_effects(&mut self.effects, self.format, visual)
309    }
310    /// The diagnostic `kind` describes, produced for `format` with the
311    /// `effects` the report will list, attributed to `element_id` at the RFC
312    /// 6901 `json_pointer`, with the non-normative `message`.
313    pub(crate) fn new(
314        kind: DiagnosticKind<'_>,
315        format: OutputFormat,
316        effects: &[DiagnosticEffect],
317        element_id: Option<&str>,
318        json_pointer: &str,
319        message: &str,
320    ) -> Result<Self, TryReserveError> {
321        let mut owned_effects = Vec::new();
322        owned_effects.try_reserve_exact(effects.len())?;
323        owned_effects.extend_from_slice(effects);
324        Ok(Self {
325            code: kind.code(),
326            severity: kind.severity(),
327            format,
328            effects: owned_effects,
329            element_id: element_id.map(try_owned).transpose()?,
330            json_pointer: try_owned(json_pointer)?,
331            message: try_owned(message)?,
332            details: Some(Box::new(kind.into_details()?)),
333        })
334    }
335
336    pub(crate) const fn code(&self) -> DiagnosticCode {
337        self.code
338    }
339
340    pub(crate) fn element_id(&self) -> Option<&str> {
341        self.element_id.as_deref()
342    }
343
344    pub(crate) fn json_pointer(&self) -> &str {
345        &self.json_pointer
346    }
347
348    /// Re-select a file-level recovery after the frame's referring images are
349    /// known. Preserve the original source pointer, message and ordering data.
350    pub(crate) fn scope_malformed_image_recovery(
351        &mut self,
352        error_status: impl FnOnce(&str) -> bool,
353    ) -> Result<(), TryReserveError> {
354        if self.code != DiagnosticCode::ASSET_MALFORMED_FILE_RECORD {
355            return Ok(());
356        }
357        if let Some(DiagnosticDetails::Asset { asset_id, .. }) = self.details.as_deref() {
358            let kind = DiagnosticKind::ImageUnavailable {
359                failure: crate::diagnostic_registry::ImageFailure::MalformedFileRecord,
360                asset_id,
361                recovery: crate::diagnostic_registry::ImageRecovery::for_format(
362                    self.format,
363                    error_status(asset_id),
364                ),
365            };
366            self.details = Some(Box::new(kind.into_details()?));
367        }
368        Ok(())
369    }
370
371    pub(crate) fn scope_forbidden_resource_recovery(
372        &mut self,
373        has_viewport: impl FnOnce(&str) -> bool,
374    ) -> Result<(), TryReserveError> {
375        if self.code != DiagnosticCode::IMAGE_FORBIDDEN_RESOURCE {
376            return Ok(());
377        }
378        if let Some(details) = self.details.as_deref_mut() {
379            if let DiagnosticDetails::Asset { asset_id, .. } = details {
380                let kind = DiagnosticKind::ForbiddenResource {
381                    asset_id,
382                    recovery: crate::diagnostic_registry::LocalRecovery::placeholder_if(
383                        has_viewport(asset_id),
384                    ),
385                };
386                *details = kind.into_details()?;
387            }
388        }
389        Ok(())
390    }
391
392    #[cfg(test)]
393    pub(crate) fn effects(&self) -> &[DiagnosticEffect] {
394        &self.effects
395    }
396
397    #[cfg(test)]
398    pub(crate) fn details(&self) -> Option<&DiagnosticDetails> {
399        self.details.as_deref()
400    }
401
402    /// The owned element ID and pointer, for a failure that names where the
403    /// diagnostic could not be retained.
404    pub(crate) fn into_location(self) -> (Option<String>, String) {
405        (self.element_id, self.json_pointer)
406    }
407
408    /// The diagnostic as the session retains it at `order` in its report.
409    pub(crate) fn retained(self, order: DiagnosticOrder) -> CompatibilityDiagnostic {
410        CompatibilityDiagnostic {
411            code: self.code,
412            severity: self.severity,
413            format: self.format,
414            effects: self.effects,
415            element_id: self.element_id,
416            json_pointer: self.json_pointer,
417            message: self.message,
418            details: self.details,
419            order,
420        }
421    }
422}
423
424fn try_owned(value: &str) -> Result<String, TryReserveError> {
425    let mut owned = String::new();
426    owned.try_reserve_exact(value.len())?;
427    owned.push_str(value);
428    Ok(owned)
429}
430
431/// Stable serializable view of one compatibility diagnostic occurrence.
432#[derive(Clone, Debug, PartialEq, Serialize)]
433pub struct CompatibilityDiagnostic {
434    code: DiagnosticCode,
435    severity: DiagnosticSeverity,
436    format: OutputFormat,
437    effects: Vec<DiagnosticEffect>,
438    element_id: Option<String>,
439    json_pointer: String,
440    message: String,
441    details: Option<Box<DiagnosticDetails>>,
442    #[serde(skip)]
443    order: DiagnosticOrder,
444}
445
446/// Where the session placed a retained diagnostic in the total report order
447/// of spec section 11.3: the source owner and member position the emitting
448/// stage attributed it to, the order it was retained in, and whether a frame
449/// export attributes it to the scene or to the target. Only
450/// [`DiagnosticOrder::retained`] builds one.
451#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
452pub(crate) struct DiagnosticOrder {
453    pub(crate) owner_index: u64,
454    pub(crate) source_position: u64,
455    pub(crate) retention_ordinal: u64,
456    pub(crate) attribution: DiagnosticAttribution,
457}
458
459#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
460pub(crate) enum DiagnosticAttribution {
461    SceneGlobal,
462    Target,
463}
464
465impl DiagnosticOrder {
466    /// The order the session gives a diagnostic it retains as the
467    /// `retention_ordinal`th of its report: the source owner and member
468    /// position the emitting stage attributed it to, or last among the owners
469    /// or positions when the stage knew neither, so root-owned and unowned
470    /// diagnostics follow every source-owned one.
471    pub(crate) fn retained(
472        owner_index: Option<u64>,
473        source_position: Option<u64>,
474        retention_ordinal: u64,
475        attribution: DiagnosticAttribution,
476    ) -> Self {
477        Self {
478            owner_index: owner_index.unwrap_or(u64::MAX),
479            source_position: source_position.unwrap_or(u64::MAX),
480            retention_ordinal,
481            attribution,
482        }
483    }
484}
485
486impl CompatibilityDiagnostic {
487    /// A rejected parent frame removes this placeholder and its surviving link.
488    pub(crate) fn omit_surface_fallback(&mut self, has_link: bool) -> Result<(), TryReserveError> {
489        if let Some(DiagnosticDetails::Compatibility {
490            recovery,
491            recipe_id,
492        }) = self.details.as_deref_mut()
493        {
494            if *recovery == crate::Recovery::Placeholder {
495                if self.code == DiagnosticCode::COMPATIBILITY_MALFORMED_ELEMENT
496                    && has_link
497                    && self.format != OutputFormat::Png
498                    && !self.effects.contains(&DiagnosticEffect::Semantic)
499                {
500                    self.effects.try_reserve(1)?;
501                    self.effects.push(DiagnosticEffect::Semantic);
502                }
503                *recovery = crate::Recovery::Omitted;
504                *recipe_id = None;
505            }
506        }
507        Ok(())
508    }
509    pub(crate) fn set_forbidden_resource_recovery(
510        &mut self,
511        paints: bool,
512    ) -> Result<(), TryReserveError> {
513        if self.code == DiagnosticCode::IMAGE_FORBIDDEN_RESOURCE {
514            if let Some(details) = self.details.as_deref_mut() {
515                if let DiagnosticDetails::Asset { asset_id, .. } = details {
516                    *details = DiagnosticKind::ForbiddenResource {
517                        asset_id,
518                        recovery: crate::diagnostic_registry::LocalRecovery::placeholder_if(paints),
519                    }
520                    .into_details()?;
521                }
522            }
523        }
524        Ok(())
525    }
526    pub(crate) fn set_identity_effects(&mut self, visual: bool) -> Result<(), TryReserveError> {
527        set_identity_effects(&mut self.effects, self.format, visual)
528    }
529    /// Returns the stable machine-readable diagnostic code.
530    pub const fn code(&self) -> DiagnosticCode {
531        self.code
532    }
533
534    /// Returns the diagnostic severity.
535    pub const fn severity(&self) -> DiagnosticSeverity {
536        self.severity
537    }
538
539    /// Returns the format whose projection produced this diagnostic.
540    pub const fn format(&self) -> OutputFormat {
541        self.format
542    }
543
544    /// Returns the nonempty canonical effect list.
545    pub fn effects(&self) -> &[DiagnosticEffect] {
546        &self.effects
547    }
548
549    /// Returns the normalized source element ID, when applicable.
550    pub fn element_id(&self) -> Option<&str> {
551        self.element_id.as_deref()
552    }
553
554    /// Returns the required RFC 6901 scene pointer.
555    pub fn json_pointer(&self) -> &str {
556        &self.json_pointer
557    }
558
559    /// Returns the human-readable explanation.
560    pub fn message(&self) -> &str {
561        &self.message
562    }
563
564    /// Returns typed details required by the diagnostic code.
565    pub fn details(&self) -> Option<&DiagnosticDetails> {
566        self.details.as_deref()
567    }
568
569    /// The details, for the one recovery the session revises after
570    /// retention: a resource placeholder it later cannot paint becomes an
571    /// omission.
572    pub(crate) fn details_mut(&mut self) -> Option<&mut DiagnosticDetails> {
573        self.details.as_deref_mut()
574    }
575
576    /// The position the session retained this diagnostic at.
577    pub(crate) const fn order(&self) -> DiagnosticOrder {
578        self.order
579    }
580
581    /// Attributes the diagnostic to the frame export target instead of the
582    /// scene, once frame scoping knows the owner participates.
583    pub(crate) fn attribute_to_target(&mut self) {
584        self.order.attribution = DiagnosticAttribution::Target;
585    }
586
587    /// Whether the diagnosed occurrence has visual placeholder/omission
588    /// recovery. A field occurrence need not replace its entire source element.
589    pub(crate) fn replaces_occurrence(&self) -> bool {
590        self.effects.contains(&DiagnosticEffect::Visual)
591            && matches!(
592                self.details().and_then(DiagnosticDetails::recovery),
593                Some(crate::Recovery::Placeholder | crate::Recovery::Omitted)
594            )
595    }
596}
597
598impl fmt::Display for CompatibilityDiagnostic {
599    /// One line for a person: `warning: MESSAGE [CODE] (element ID)`, the
600    /// element part only when the diagnostic names one.
601    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
602        write!(
603            formatter,
604            "{}: {} [{}]",
605            self.severity, self.message, self.code
606        )?;
607        if let Some(element_id) = &self.element_id {
608            write!(formatter, " (element {element_id})")?;
609        }
610        Ok(())
611    }
612}
613
614/// Complete ordered compatibility diagnostics for one render transaction.
615#[derive(Clone, Debug, PartialEq, Serialize)]
616pub struct CompatibilityReport {
617    pub(crate) schema_version: u32,
618    pub(crate) diagnostics: Vec<CompatibilityDiagnostic>,
619}
620
621impl CompatibilityReport {
622    /// Creates a standalone empty schema-1 report.
623    pub const fn empty() -> Self {
624        Self {
625            schema_version: 1,
626            diagnostics: Vec::new(),
627        }
628    }
629
630    /// Returns the stable report schema version.
631    pub const fn schema_version(&self) -> u32 {
632        self.schema_version
633    }
634
635    /// Returns the complete diagnostics in canonical order.
636    pub fn diagnostics(&self) -> &[CompatibilityDiagnostic] {
637        &self.diagnostics
638    }
639
640    /// Returns whether the report contains no diagnostics.
641    pub fn is_empty(&self) -> bool {
642        self.diagnostics.is_empty()
643    }
644
645    /// Returns the number of diagnostic occurrences without coalescing.
646    pub fn len(&self) -> usize {
647        self.diagnostics.len()
648    }
649}
650
651impl Default for CompatibilityReport {
652    fn default() -> Self {
653        Self::empty()
654    }
655}
656
657/// One complete renderer-owned static export.
658#[derive(Clone, Debug, Eq, PartialEq)]
659pub struct Artifact {
660    pub(crate) format: OutputFormat,
661    pub(crate) bytes: Vec<u8>,
662}
663
664impl Artifact {
665    /// Returns the artifact's output format.
666    pub const fn format(&self) -> OutputFormat {
667        self.format
668    }
669
670    /// Returns the canonical media type for the artifact format.
671    pub const fn media_type(&self) -> &'static str {
672        self.format.media_type()
673    }
674
675    /// Borrows the complete artifact bytes.
676    pub fn bytes(&self) -> &[u8] {
677        &self.bytes
678    }
679
680    /// Consumes the artifact and returns its complete owned bytes.
681    pub fn into_bytes(self) -> Vec<u8> {
682        self.bytes
683    }
684}
685
686/// Successful result of one complete render transaction.
687#[derive(Clone, Debug)]
688pub struct RenderOutcome {
689    pub(crate) artifact: Artifact,
690    pub(crate) report: CompatibilityReport,
691    pub(crate) profile: &'static ReferenceProfileIdentity,
692}
693
694impl RenderOutcome {
695    /// Returns the single complete artifact.
696    pub const fn artifact(&self) -> &Artifact {
697        &self.artifact
698    }
699
700    /// Returns the complete ordered compatibility report.
701    pub const fn report(&self) -> &CompatibilityReport {
702        &self.report
703    }
704
705    /// Consumes the outcome without discarding either complete owned value.
706    pub fn into_parts(self) -> (Artifact, CompatibilityReport) {
707        (self.artifact, self.report)
708    }
709
710    /// Returns the reference profile the artifact reproduces: the scene profile
711    /// for a scene export, the frame profile for a frame export.
712    #[doc(hidden)]
713    pub const fn profile_identity(&self) -> &'static ReferenceProfileIdentity {
714        self.profile
715    }
716}
717
718/// Stable category of a fatal render failure.
719#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
720#[non_exhaustive]
721#[serde(rename_all = "snake_case")]
722pub enum RenderErrorCategory {
723    /// Admitted scene input could not produce bounded global semantics.
724    Input,
725    /// A capability required by the render is unavailable in this build.
726    Capability,
727    /// A configured semantic resource ceiling was crossed.
728    ResourceLimit,
729    /// Rendering could not retain a meaningful complete artifact.
730    NoMeaningfulArtifact,
731    /// Complete artifact encoding failed.
732    Encoding,
733    /// A trusted renderer invariant failed.
734    Internal,
735}
736
737impl RenderErrorCategory {
738    /// Returns the stable serialized spelling.
739    pub const fn as_str(self) -> &'static str {
740        match self {
741            Self::Input => "input",
742            Self::Capability => "capability",
743            Self::ResourceLimit => "resource_limit",
744            Self::NoMeaningfulArtifact => "no_meaningful_artifact",
745            Self::Encoding => "encoding",
746            Self::Internal => "internal",
747        }
748    }
749}
750
751impl fmt::Display for RenderErrorCategory {
752    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
753        formatter.write_str(self.as_str())
754    }
755}
756
757/// Fatal render failure with the complete report accumulated before failure.
758///
759/// [`Error::source`] is always `None`: dependency failures (decoders,
760/// encoders, font tooling) are flattened into the stable code and message.
761#[derive(Clone, Debug)]
762pub struct RenderError {
763    pub(crate) category: RenderErrorCategory,
764    pub(crate) code: ErrorCode,
765    pub(crate) message: Box<str>,
766    pub(crate) format: OutputFormat,
767    pub(crate) element_id: Option<String>,
768    pub(crate) json_pointer: Box<str>,
769    pub(crate) details: Option<Box<DiagnosticDetails>>,
770    pub(crate) report: CompatibilityReport,
771}
772
773impl RenderError {
774    /// Returns the stable fatal failure category.
775    pub const fn category(&self) -> RenderErrorCategory {
776        self.category
777    }
778
779    /// Returns the stable machine-readable error code.
780    pub const fn code(&self) -> ErrorCode {
781        self.code
782    }
783
784    /// Returns the selected output format.
785    pub const fn format(&self) -> OutputFormat {
786        self.format
787    }
788
789    /// Returns the normalized source element ID, when applicable.
790    pub fn element_id(&self) -> Option<&str> {
791        self.element_id.as_deref()
792    }
793
794    /// Returns the required RFC 6901 scene pointer.
795    pub fn json_pointer(&self) -> &str {
796        &self.json_pointer
797    }
798
799    /// Returns typed details required by the stable error code.
800    pub fn details(&self) -> Option<&DiagnosticDetails> {
801        self.details.as_deref()
802    }
803
804    /// Returns the complete ordered report accumulated before failure.
805    pub const fn report(&self) -> &CompatibilityReport {
806        &self.report
807    }
808
809    /// Returns this failure's stable serializable descriptor.
810    pub fn descriptor(&self) -> ErrorDescriptor {
811        ErrorDescriptor::render(
812            self.category,
813            self.code,
814            self.to_string(),
815            self.format,
816            self.element_id.clone(),
817            self.json_pointer.to_string(),
818            self.details.as_deref().cloned(),
819        )
820    }
821}
822
823impl fmt::Display for RenderError {
824    /// The failure's message; a limit crossing appends the measured value,
825    /// the limit field and its configured ceiling.
826    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
827        formatter.write_str(&self.message)?;
828        if let Some(crossing) = self.details().and_then(DiagnosticDetails::limit_crossing) {
829            write!(formatter, ": {crossing}")?;
830        }
831        Ok(())
832    }
833}
834
835impl Error for RenderError {}
836
837#[cfg(test)]
838mod tests {
839    use super::*;
840    use crate::diagnostic_registry::{ImageFailure, ImageRecovery, LocalRecovery};
841    use crate::{Renderer, ResourceMeasurement, ResourceValue, ValueKind};
842
843    /// A diagnostic retained first, unowned, in a scene export.
844    fn retained_first(pending: PendingDiagnostic) -> CompatibilityDiagnostic {
845        pending.retained(DiagnosticOrder::retained(
846            None,
847            None,
848            0,
849            DiagnosticAttribution::SceneGlobal,
850        ))
851    }
852
853    #[test]
854    fn owned_outcome_and_render_failure_expose_only_complete_contract_values() {
855        let diagnostic = retained_first(
856            PendingDiagnostic::new(
857                DiagnosticKind::MalformedElement(LocalRecovery::Placeholder),
858                OutputFormat::Svg,
859                &[DiagnosticEffect::Visual],
860                Some("element-1"),
861                "/elements/0",
862                "malformed element replaced",
863            )
864            .expect("the diagnostic allocates"),
865        );
866        let report = CompatibilityReport {
867            schema_version: 1,
868            diagnostics: vec![diagnostic],
869        };
870        let profile = Renderer::new().profile_identity();
871        let outcome = RenderOutcome {
872            artifact: Artifact {
873                format: OutputFormat::Svg,
874                bytes: b"<svg/>".to_vec(),
875            },
876            report: report.clone(),
877            profile,
878        };
879
880        assert_eq!(outcome.artifact().bytes(), b"<svg/>");
881        assert_eq!(outcome.artifact().media_type(), "image/svg+xml");
882        assert_eq!(outcome.report(), &report);
883        assert!(std::ptr::eq(outcome.profile_identity(), profile));
884        assert_eq!(
885            serde_json::to_string(outcome.report()).expect("serialize diagnosed report"),
886            concat!(
887                "{\"schema_version\":1,\"diagnostics\":[{",
888                "\"code\":\"compatibility.malformed-element\",",
889                "\"severity\":\"warning\",",
890                "\"format\":\"svg\",",
891                "\"effects\":[\"visual\"],",
892                "\"element_id\":\"element-1\",",
893                "\"json_pointer\":\"/elements/0\",",
894                "\"message\":\"malformed element replaced\",",
895                "\"details\":{\"kind\":\"compatibility\",",
896                "\"recovery\":\"placeholder\",",
897                "\"recipe_id\":\"renderer.malformed-element.v1\"}}]}"
898            )
899        );
900        let (artifact, returned_report) = outcome.into_parts();
901        assert_eq!(artifact.into_bytes(), b"<svg/>");
902        assert_eq!(returned_report, report);
903
904        let error = RenderError {
905            category: RenderErrorCategory::ResourceLimit,
906            code: ErrorCode::RESOURCE_OUTPUT_BYTES_EXCEEDED,
907            message: "output byte limit exceeded".into(),
908            format: OutputFormat::Svg,
909            element_id: None,
910            json_pointer: Box::from(""),
911            details: Some(Box::new(DiagnosticDetails::ResourceLimit {
912                field: "max_output_bytes",
913                configured: ResourceValue::Integer(134_217_728),
914                value_kind: ValueKind::Bytes,
915                measurement: ResourceMeasurement::Attempted {
916                    value: ResourceValue::Integer(134_217_729),
917                },
918                recovery: None,
919            })),
920            report: returned_report,
921        };
922        assert_eq!(error.report().len(), 1);
923        assert_eq!(error.category(), RenderErrorCategory::ResourceLimit);
924        assert_eq!(
925            serde_json::to_string(&error.descriptor()).expect("serialize render descriptor"),
926            concat!(
927                "{\"family\":\"render\",",
928                "\"category\":\"resource_limit\",",
929                "\"code\":\"resource.output-bytes-exceeded\",",
930                "\"message\":\"output byte limit exceeded: needs 134217729 bytes, ",
931                "but max_output_bytes is 134217728\",",
932                "\"format\":\"svg\",",
933                "\"element_id\":null,",
934                "\"json_pointer\":\"\",",
935                "\"details\":{\"kind\":\"resource_limit\",",
936                "\"field\":\"max_output_bytes\",",
937                "\"configured\":134217728,",
938                "\"value_kind\":\"bytes\",",
939                "\"measurement\":{\"kind\":\"attempted\",\"value\":134217729},",
940                "\"recovery\":null}}"
941            )
942        );
943    }
944
945    #[test]
946    fn report_preserves_every_diagnostic_in_supplied_canonical_order() {
947        let diagnostic = |kind, pointer: &str| {
948            retained_first(
949                PendingDiagnostic::new(
950                    kind,
951                    OutputFormat::Png,
952                    &[DiagnosticEffect::Visual],
953                    None,
954                    pointer,
955                    "diagnosed",
956                )
957                .expect("the diagnostic allocates"),
958            )
959        };
960        let report = CompatibilityReport {
961            schema_version: 1,
962            diagnostics: vec![
963                diagnostic(DiagnosticKind::InvalidColor, "/elements/0/strokeColor"),
964                diagnostic(
965                    DiagnosticKind::ImageUnavailable {
966                        failure: ImageFailure::DecodeFailed {
967                            media_type: "image/png",
968                        },
969                        asset_id: "image",
970                        recovery: ImageRecovery::for_format(OutputFormat::Png, false),
971                    },
972                    "/files/image/dataURL",
973                ),
974            ],
975        };
976
977        assert_eq!(report.len(), 2);
978        assert_eq!(
979            report.diagnostics()[0].code(),
980            DiagnosticCode::COLOR_INVALID
981        );
982        assert_eq!(
983            report.diagnostics()[1].code(),
984            DiagnosticCode::IMAGE_DECODE_FAILED
985        );
986    }
987}