Skip to main content

openbim_step/
recovery.rs

1//! Opt-in malformed-record recovery policy and non-fatal diagnostics.
2//!
3//! Strict parsing is the default because an authoring tool that silently drops
4//! data corrupts the model it is editing. A consumer (viewer, importer,
5//! reporter) has the opposite need: real exporter output contains occasional
6//! damaged records, and refusing an entire file over one of them is not useful.
7//!
8//! Recovery is therefore explicit, bounded, and reported: the caller opts in,
9//! only data records are recoverable, and every skipped byte range comes back
10//! as a [`Diagnostic`] so a consumer can show what was lost instead of
11//! pretending the file was clean.
12
13use crate::{Exchange, InstanceId, Span};
14use std::fmt;
15
16/// What to do when a data record cannot be parsed.
17#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
18pub enum OnMalformed {
19    /// Fail the parse. The default: an unreadable record is a hard error.
20    #[default]
21    Abort,
22    /// Report the record as a diagnostic, resynchronize, and keep parsing.
23    Skip,
24}
25
26/// Parse behavior toggles.
27///
28/// Constructed with [`ParseOptions::default`] (strict) and adjusted through
29/// [`ParseOptions::on_malformed_record`], so later options cannot break
30/// existing call sites.
31#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
32#[non_exhaustive]
33pub struct ParseOptions {
34    /// Policy for unparsable data records.
35    pub on_malformed_record: OnMalformed,
36    /// Whether to report duplicate instance ids and references to ids that
37    /// are never defined. Off by default: strict parsing means syntax only.
38    pub check_references: bool,
39}
40
41impl ParseOptions {
42    /// Strict options: any malformed record aborts the parse.
43    #[must_use]
44    pub const fn strict() -> Self {
45        Self {
46            on_malformed_record: OnMalformed::Abort,
47            check_references: false,
48        }
49    }
50
51    /// Options that skip and report malformed data records.
52    #[must_use]
53    pub const fn lenient() -> Self {
54        Self {
55            on_malformed_record: OnMalformed::Skip,
56            check_references: false,
57        }
58    }
59
60    /// Sets the malformed-record policy.
61    #[must_use]
62    pub const fn on_malformed_record(mut self, policy: OnMalformed) -> Self {
63        self.on_malformed_record = policy;
64        self
65    }
66
67    /// Enables or disables reference-integrity diagnostics.
68    ///
69    /// When enabled, every data record whose instance id was already defined
70    /// yields a [`DiagnosticKind::DuplicateId`], and every record referencing
71    /// an id that no record in the `DATA` section defines yields one
72    /// [`DiagnosticKind::DanglingReference`] per distinct missing id. Forward
73    /// references are legal (ISO 10303-21:2016 §11.2) and are only reported
74    /// if the target is still undefined at `ENDSEC`. Ids compare numerically,
75    /// so `#07` and `#7` name the same instance.
76    ///
77    /// Nothing is dropped or rewritten, so these diagnostics never make an
78    /// outcome lossy. The check keeps one entry per defined id, so memory is
79    /// linear in the number of records, including for [`crate::parse_events_with`].
80    #[must_use]
81    pub const fn check_references(mut self, enabled: bool) -> Self {
82        self.check_references = enabled;
83        self
84    }
85}
86
87/// Severity of a non-fatal parse diagnostic.
88///
89/// Only [`Severity::Warning`] exists today: every diagnostic describes input
90/// that was accepted. Fatal problems are returned as
91/// [`StepError`](crate::StepError) instead of being reported here.
92#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
93#[non_exhaustive]
94pub enum Severity {
95    /// Input was accepted, but is damaged or inconsistent.
96    #[default]
97    Warning,
98}
99
100/// What a [`Diagnostic`] reports.
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
102#[non_exhaustive]
103pub enum DiagnosticKind {
104    /// A malformed data record was skipped under [`OnMalformed::Skip`]. The
105    /// only kind that loses input.
106    SkippedRecord,
107    /// A data record reuses an instance id defined earlier in the section.
108    /// ISO 10303-21:2016 §11.2 requires instance names to be unique. Both
109    /// records are kept; the diagnostic points at the later one.
110    DuplicateId,
111    /// A data record references an instance id that no record defines.
112    DanglingReference,
113}
114
115/// A non-fatal problem found while parsing.
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub struct Diagnostic {
118    severity: Severity,
119    kind: DiagnosticKind,
120    span: Span,
121    instance: Option<InstanceId>,
122    detail: String,
123}
124
125impl Diagnostic {
126    pub(crate) fn skipped_record(span: Span, detail: impl Into<String>) -> Self {
127        Self {
128            severity: Severity::Warning,
129            kind: DiagnosticKind::SkippedRecord,
130            span,
131            instance: None,
132            detail: detail.into(),
133        }
134    }
135
136    pub(crate) fn duplicate_id(span: Span, id: InstanceId) -> Self {
137        Self {
138            severity: Severity::Warning,
139            kind: DiagnosticKind::DuplicateId,
140            span,
141            detail: format!("duplicate instance id {id}"),
142            instance: Some(id),
143        }
144    }
145
146    pub(crate) fn dangling_reference(span: Span, id: InstanceId) -> Self {
147        Self {
148            severity: Severity::Warning,
149            kind: DiagnosticKind::DanglingReference,
150            span,
151            detail: format!("reference to undefined instance {id}"),
152            instance: Some(id),
153        }
154    }
155
156    /// Severity of the diagnostic.
157    #[must_use]
158    pub const fn severity(&self) -> Severity {
159        self.severity
160    }
161
162    /// What the diagnostic reports.
163    #[must_use]
164    pub const fn kind(&self) -> DiagnosticKind {
165        self.kind
166    }
167
168    /// The instance id at fault. For a duplicate it is the later record's id
169    /// as written; for a dangling reference it is the missing id in canonical
170    /// form (leading zeros removed, since `#07` and `#7` are the same
171    /// instance). `None` for a skipped record.
172    #[must_use]
173    pub const fn instance(&self) -> Option<&InstanceId> {
174        self.instance.as_ref()
175    }
176
177    /// Byte range of the original input that the diagnostic covers.
178    ///
179    /// For a skipped record this is the whole discarded range, so a consumer
180    /// can quote the exact bytes that were dropped. For a reference defect it
181    /// is the offending record: the later duplicate, or the record holding the
182    /// dangling reference.
183    #[must_use]
184    pub const fn span(&self) -> Span {
185        self.span
186    }
187
188    /// Human-readable description without a location prefix.
189    #[must_use]
190    pub fn detail(&self) -> &str {
191        &self.detail
192    }
193}
194
195impl fmt::Display for Diagnostic {
196    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
197        write!(
198            formatter,
199            "STEP warning at bytes {}..{}: {}",
200            self.span.start, self.span.end, self.detail
201        )
202    }
203}
204
205/// A parsed exchange together with everything that was recovered with loss.
206#[derive(Debug, Clone, PartialEq)]
207pub struct ParseOutcome {
208    /// Records that were read successfully.
209    pub exchange: Exchange,
210    /// Non-fatal problems, in source order. Empty for a clean file.
211    pub diagnostics: Vec<Diagnostic>,
212}
213
214impl ParseOutcome {
215    /// Whether nothing was dropped while reading.
216    ///
217    /// Only [`DiagnosticKind::SkippedRecord`] loses input. Reference
218    /// defects are reported but keep every record, so they do not count.
219    #[must_use]
220    pub fn is_lossless(&self) -> bool {
221        !self
222            .diagnostics
223            .iter()
224            .any(|diagnostic| diagnostic.kind == DiagnosticKind::SkippedRecord)
225    }
226}