Skip to main content

step_io/parser/
schema.rs

1/// A Part 21 `LIST[1:?] OF STRING` — guaranteed to hold at least one element.
2///
3/// STEP's `FILE_DESCRIPTION.description`, `FILE_NAME.author`, and
4/// `FILE_NAME.organization` fields are typed `LIST[1:?] OF STRING`; an empty
5/// list is a spec violation. Encoding that constraint at the type level
6/// prevents construction of spec-violating `FileHeader` values: any attempt
7/// to build a `NonEmptyStringList` from an empty `Vec<String>` returns
8/// `None` rather than an invalid value.
9///
10/// STEP convention for "no meaningful content" is a single-element list
11/// holding `""`, which is what [`NonEmptyStringList::default`] produces.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct NonEmptyStringList(Vec<String>);
14
15impl NonEmptyStringList {
16    /// A single-element list (used for the spec-compliant "no content" form
17    /// `('')` via `Default`).
18    fn single(s: String) -> Self {
19        Self(vec![s])
20    }
21
22    /// Lift a `Vec<String>` to `NonEmptyStringList`; returns `None` for an
23    /// empty input.
24    #[must_use]
25    pub fn try_from_vec(v: Vec<String>) -> Option<Self> {
26        if v.is_empty() { None } else { Some(Self(v)) }
27    }
28
29    #[must_use]
30    pub fn as_slice(&self) -> &[String] {
31        &self.0
32    }
33
34    /// Inherent iterator (idiomatic companion to `IntoIterator for &Self`).
35    pub fn iter(&self) -> std::slice::Iter<'_, String> {
36        self.0.iter()
37    }
38
39    #[must_use]
40    pub fn len(&self) -> usize {
41        self.0.len()
42    }
43
44    #[must_use]
45    pub fn is_empty(&self) -> bool {
46        // Invariant: never empty. Provided for `clippy::len_without_is_empty`.
47        false
48    }
49}
50
51impl Default for NonEmptyStringList {
52    /// Single empty-string element (`[""]`) — the STEP convention for
53    /// "no meaningful content" while remaining spec-compliant.
54    fn default() -> Self {
55        Self::single(String::new())
56    }
57}
58
59impl<'a> IntoIterator for &'a NonEmptyStringList {
60    type Item = &'a String;
61    type IntoIter = std::slice::Iter<'a, String>;
62
63    fn into_iter(self) -> Self::IntoIter {
64        self.0.iter()
65    }
66}
67
68/// AP family recognised from `FILE_SCHEMA`. `Other` = step-io did not
69/// recognise the schema name (the raw text is still preserved in [`SchemaId`]).
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
71pub enum ApFamily {
72    Ap203,
73    #[default]
74    Ap214,
75    Ap242,
76    Other,
77}
78
79/// ISO publication stage of a schema edition.
80///
81/// `edition` and `stage` are independent axes: a single AP edition passes
82/// through `Cd` → `Dis` → `Is`. Use `stage == Is` (not the presence of an
83/// edition) to test whether the file declares a published standard.
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
85pub enum Stage {
86    /// International Standard (published).
87    Is,
88    /// Draft International Standard.
89    Dis,
90    /// Committee Draft.
91    Cd,
92    /// Stage could not be determined from the `FILE_SCHEMA`.
93    #[default]
94    Unknown,
95}
96
97/// Structured identity of a file's `FILE_SCHEMA`: AP family, edition, and
98/// stage, plus the raw text.
99///
100/// Derived by [`identify_schema`] from the `FILE_SCHEMA` descriptor — the
101/// schema-version token `{ ... 10303 <part> <version> ... }` is parsed
102/// generically (whitespace/arity tolerant) and mapped through the
103/// per-family catalog; an unrecognised name falls back to
104/// [`ApFamily::Other`] and an unrecognised version to `edition: None`, so
105/// future editions are still recognised by family.
106///
107/// - `raw: Some(_)` — preserved verbatim from a source file (read path);
108///   byte-exact round-trip relies on this.
109/// - `raw: None` — a synthetic model (kernel-built); the writer supplies a
110///   canonical `FILE_SCHEMA` for the chosen target.
111#[derive(Debug, Clone, PartialEq, Eq, Default)]
112pub struct SchemaId {
113    pub family: ApFamily,
114    /// AP edition (1, 2, 3, …); `None` when the token does not pin it
115    /// (e.g. AP214 CD/DIS, or an unrecognised AP242 module version).
116    pub edition: Option<u8>,
117    pub stage: Stage,
118    pub raw: Option<NonEmptyStringList>,
119}
120
121impl SchemaId {
122    /// A synthetic identity with no preserved raw text (kernel-built model).
123    #[must_use]
124    pub fn synthetic(family: ApFamily, edition: Option<u8>, stage: Stage) -> Self {
125        Self {
126            family,
127            edition,
128            stage,
129            raw: None,
130        }
131    }
132
133    /// Whether step-io recognised the AP family.
134    #[must_use]
135    pub fn is_recognized(&self) -> bool {
136        self.family != ApFamily::Other
137    }
138
139    /// Preserved raw `FILE_SCHEMA` text, if any (`None` for synthetic).
140    #[must_use]
141    pub fn raw(&self) -> Option<&NonEmptyStringList> {
142        self.raw.as_ref()
143    }
144}
145
146impl std::fmt::Display for SchemaId {
147    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148        let fam = match self.family {
149            ApFamily::Ap203 => "AP203",
150            ApFamily::Ap214 => "AP214",
151            ApFamily::Ap242 => "AP242",
152            ApFamily::Other => return write!(f, "unrecognized schema"),
153        };
154        let stage = match self.stage {
155            Stage::Is => "IS",
156            Stage::Dis => "DIS",
157            Stage::Cd => "CD",
158            Stage::Unknown => "?",
159        };
160        match self.edition {
161            Some(e) => write!(f, "{fam} ed{e} ({stage})"),
162            None => write!(f, "{fam} ed? ({stage})"),
163        }
164    }
165}
166
167/// Identify the [`SchemaId`] from the raw `FILE_SCHEMA` string list.
168///
169/// The schema-version token is parsed generically and mapped via the catalog
170/// (`internal/SCHEMA_IDENTIFIER_CATALOG.md`); names without a usable token
171/// fall back to family recognition. The raw text is always preserved.
172#[must_use]
173pub fn identify_schema(file_schema: &[String]) -> SchemaId {
174    let Some(raw) = NonEmptyStringList::try_from_vec(file_schema.to_vec()) else {
175        // Spec violation: FILE_SCHEMA is LIST[1:?]. Lenient recovery.
176        return SchemaId::default();
177    };
178    let (family, edition, stage) = classify(file_schema);
179    SchemaId {
180        family,
181        edition,
182        stage,
183        raw: Some(raw),
184    }
185}
186
187/// Classify the `FILE_SCHEMA` list. Returns the first entry that identifies a
188/// family; `(Other, None, Unknown)` if none match.
189fn classify(file_schema: &[String]) -> (ApFamily, Option<u8>, Stage) {
190    for s in file_schema {
191        if let Some(hit) = classify_one(&s.to_uppercase()) {
192            return hit;
193        }
194    }
195    (ApFamily::Other, None, Stage::Unknown)
196}
197
198/// Classify a single (already upper-cased) `FILE_SCHEMA` descriptor. Token
199/// parsing is primary (pins the edition); the name fallback handles
200/// descriptors without a usable `{ ... }` token.
201fn classify_one(upper: &str) -> Option<(ApFamily, Option<u8>, Stage)> {
202    if let Some((part, version)) = parse_token(upper) {
203        match part {
204            214 => {
205                use std::cmp::Ordering;
206                let (ed, stage) = match version.cmp(&0) {
207                    Ordering::Less => (None, Stage::Cd),
208                    Ordering::Equal => (None, Stage::Dis),
209                    Ordering::Greater => (u8::try_from(version).ok(), Stage::Is),
210                };
211                return Some((ApFamily::Ap214, ed, stage));
212            }
213            403 | 203 => {
214                let (ed, stage) = if version >= 1 {
215                    (u8::try_from(version).ok(), Stage::Is)
216                } else {
217                    (None, Stage::Unknown)
218                };
219                return Some((ApFamily::Ap203, ed, stage));
220            }
221            442 => {
222                // AP242: the module (ISO/TS 10303-442) version is non-linear
223                // with the AP242 edition, and the `1 0` prefix does not mark
224                // the stage — so map version → (edition, stage) explicitly.
225                let (ed, stage) = match version {
226                    1 => (Some(1), Stage::Is),
227                    2 => (Some(2), Stage::Dis),
228                    3 => (Some(2), Stage::Is),
229                    4 => (Some(3), Stage::Is),
230                    _ => (None, Stage::Unknown),
231                };
232                return Some((ApFamily::Ap242, ed, stage));
233            }
234            _ => {} // unknown part — fall through to name matching
235        }
236    }
237    // Name fallback (no usable token).
238    if upper.contains("AUTOMOTIVE_DESIGN_CC1") || upper.contains("AUTOMOTIVE_DESIGN_CC2") {
239        return Some((ApFamily::Ap214, None, Stage::Cd));
240    }
241    if upper.contains("AUTOMOTIVE_DESIGN") {
242        return Some((ApFamily::Ap214, None, Stage::Unknown));
243    }
244    if upper.contains("AP242_MANAGED_MODEL_BASED_3D_ENGINEERING") {
245        return Some((ApFamily::Ap242, None, Stage::Is));
246    }
247    if upper.contains("AP203_CONFIGURATION_CONTROLLED") {
248        // Modular ed2 long form without a token — edition not pinnable.
249        return Some((ApFamily::Ap203, None, Stage::Is));
250    }
251    if upper.contains("CONFIG_CONTROL_DESIGN")
252        || upper.contains("CONFIGURATION_CONTROLLED_3D_DESIGN")
253    {
254        // AP203 ed1 short/long form (no token).
255        return Some((ApFamily::Ap203, Some(1), Stage::Is));
256    }
257    None
258}
259
260/// Parse the schema-version token `{ ... 10303 <part> <version> ... }`.
261///
262/// Whitespace- and arity-tolerant: splits the `{ ... }` content on
263/// whitespace, finds the exact integer token `10303`, and returns the next
264/// two integers `(part, version)` (`version` may be negative). Returns `None`
265/// when there is no brace group or no `10303` marker.
266fn parse_token(upper: &str) -> Option<(i64, i64)> {
267    let start = upper.find('{')?;
268    let rel_end = upper[start..].find('}')?;
269    let inner = &upper[start + 1..start + rel_end];
270    let toks: Vec<&str> = inner.split_whitespace().collect();
271    let pos = toks.iter().position(|t| *t == "10303")?;
272    let part: i64 = toks.get(pos + 1)?.parse().ok()?;
273    let version: i64 = toks.get(pos + 2)?.parse().ok()?;
274    Some((part, version))
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    fn id(input: &str) -> SchemaId {
282        identify_schema(&[input.into()])
283    }
284
285    // --- AP214 (version = edition, linear) ---
286    #[test]
287    fn ap214_is_editions() {
288        assert_eq!(
289            (
290                id("AUTOMOTIVE_DESIGN { 1 0 10303 214 1 1 1 1 }").family,
291                id("AUTOMOTIVE_DESIGN { 1 0 10303 214 1 1 1 1 }").edition,
292                id("AUTOMOTIVE_DESIGN { 1 0 10303 214 1 1 1 1 }").stage
293            ),
294            (ApFamily::Ap214, Some(1), Stage::Is),
295        );
296        let e2 = id("AUTOMOTIVE_DESIGN { 1 0 10303 214 2 1 1 }");
297        assert_eq!(
298            (e2.family, e2.edition, e2.stage),
299            (ApFamily::Ap214, Some(2), Stage::Is)
300        );
301        let e3 = id("AUTOMOTIVE_DESIGN { 1 0 10303 214 3 1 1 }");
302        assert_eq!(
303            (e3.family, e3.edition, e3.stage),
304            (ApFamily::Ap214, Some(3), Stage::Is)
305        );
306    }
307
308    #[test]
309    fn ap214_cd_dis() {
310        let cc2 = id("AUTOMOTIVE_DESIGN_CC2 { 1 2 10303 214 -1 1 5 4 }");
311        assert_eq!(
312            (cc2.family, cc2.edition, cc2.stage),
313            (ApFamily::Ap214, None, Stage::Cd)
314        );
315        let cc1 = id("AUTOMOTIVE_DESIGN_CC1 { 1 2 10303 214 -1 1 3 2 }");
316        assert_eq!(
317            (cc1.family, cc1.edition, cc1.stage),
318            (ApFamily::Ap214, None, Stage::Cd)
319        );
320        let dis = id("AUTOMOTIVE_DESIGN { 1 2 10303 214 0 1 1 1 }");
321        assert_eq!(
322            (dis.family, dis.edition, dis.stage),
323            (ApFamily::Ap214, None, Stage::Dis)
324        );
325    }
326
327    #[test]
328    fn ap214_bare_name_and_whitespace_variants() {
329        let bare = id("AUTOMOTIVE_DESIGN");
330        assert_eq!(
331            (bare.family, bare.edition, bare.stage),
332            (ApFamily::Ap214, None, Stage::Unknown)
333        );
334        let cc2_bare = id("AUTOMOTIVE_DESIGN_CC2");
335        assert_eq!(
336            (cc2_bare.family, cc2_bare.stage),
337            (ApFamily::Ap214, Stage::Cd)
338        );
339        // No-space and 7-number arity variants both parse to ed3 IS.
340        let nospace = id("AUTOMOTIVE_DESIGN {1 0 10303 214 3 1 1}");
341        assert_eq!(
342            (nospace.family, nospace.edition),
343            (ApFamily::Ap214, Some(3))
344        );
345        let seven = id("AUTOMOTIVE_DESIGN { 1 0 10303 214 3 1 1 1 }");
346        assert_eq!((seven.family, seven.edition), (ApFamily::Ap214, Some(3)));
347    }
348
349    // --- AP242 (version != edition, non-linear) ---
350    #[test]
351    fn ap242_version_to_edition_nonlinear() {
352        let cases = [
353            (1, Some(1), Stage::Is),
354            (2, Some(2), Stage::Dis),
355            (3, Some(2), Stage::Is),
356            (4, Some(3), Stage::Is),
357        ];
358        for (v, ed, st) in cases {
359            let s = id(&format!(
360                "AP242_MANAGED_MODEL_BASED_3D_ENGINEERING_MIM_LF {{ 1 0 10303 442 {v} 1 4 }}"
361            ));
362            assert_eq!(
363                (s.family, s.edition, s.stage),
364                (ApFamily::Ap242, ed, st),
365                "version {v}"
366            );
367        }
368    }
369
370    #[test]
371    fn ap242_unknown_version_falls_back_by_name() {
372        // ed4-style: unknown module version → recognised as AP242, edition unknown.
373        let s = id("AP242_MANAGED_MODEL_BASED_3D_ENGINEERING_MIM_LF { 1 0 10303 442 5 1 4 }");
374        assert_eq!(
375            (s.family, s.edition, s.stage),
376            (ApFamily::Ap242, None, Stage::Unknown)
377        );
378    }
379
380    #[test]
381    fn ap242_trailing_dot_and_no_space() {
382        // Real FreeCAD output: trailing `.` after name, no space after `{`.
383        let s = id("AP242_MANAGED_MODEL_BASED_3D_ENGINEERING_MIM_LF. {1 0 10303 442 1 1 4 }");
384        assert_eq!(
385            (s.family, s.edition, s.stage),
386            (ApFamily::Ap242, Some(1), Stage::Is)
387        );
388    }
389
390    // --- AP203 ---
391    #[test]
392    fn ap203_ed1_short_form() {
393        let s = id("CONFIG_CONTROL_DESIGN");
394        assert_eq!(
395            (s.family, s.edition, s.stage),
396            (ApFamily::Ap203, Some(1), Stage::Is)
397        );
398    }
399
400    #[test]
401    fn ap203_ed2_long_form_and_part_variant() {
402        let e2 = id(
403            "AP203_CONFIGURATION_CONTROLLED_3D_DESIGN_OF_MECHANICAL_PARTS_AND_ASSEMBLIES_MIM_LF { 1 0 10303 403 2 1 2 }",
404        );
405        assert_eq!(
406            (e2.family, e2.edition, e2.stage),
407            (ApFamily::Ap203, Some(2), Stage::Is)
408        );
409        // PART variant: AP203 long form carrying part `203` instead of `403` (seen in corpus).
410        let p203 = id(
411            "AP203_CONFIGURATION_CONTROLLED_3D_DESIGN_OF_MECHANICAL_PARTS_AND_ASSEMBLIES_MIM_LF { 1 0 10303 203 3 1 4 }",
412        );
413        assert_eq!(p203.family, ApFamily::Ap203);
414    }
415
416    #[test]
417    fn ap203_two_element_list_first_matches() {
418        let s = identify_schema(&[
419            "CONFIG_CONTROL_DESIGN".into(),
420            "SHAPE_APPEARANCE_LAYER_MIM".into(),
421        ]);
422        assert_eq!(s.family, ApFamily::Ap203);
423        assert_eq!(s.raw().map(NonEmptyStringList::len), Some(2));
424    }
425
426    // --- fallback / edge ---
427    #[test]
428    fn unrecognized_is_other_with_raw() {
429        let s = id("SOMETHING_ELSE");
430        assert_eq!(s.family, ApFamily::Other);
431        assert!(!s.is_recognized());
432        assert_eq!(
433            s.raw().map(|r| r.as_slice()[0].as_str()),
434            Some("SOMETHING_ELSE")
435        );
436    }
437
438    #[test]
439    fn empty_list_falls_back_to_default() {
440        // Spec violation (empty FILE_SCHEMA) → lenient default.
441        assert_eq!(identify_schema(&[]), SchemaId::default());
442    }
443
444    #[test]
445    fn raw_is_always_preserved_on_read() {
446        let s = id("AUTOMOTIVE_DESIGN { 1 0 10303 214 3 1 1 }");
447        assert_eq!(
448            s.raw().map(|r| r.as_slice()[0].as_str()),
449            Some("AUTOMOTIVE_DESIGN { 1 0 10303 214 3 1 1 }")
450        );
451    }
452
453    #[test]
454    fn display_formats() {
455        assert_eq!(
456            id("AP242_MANAGED_MODEL_BASED_3D_ENGINEERING_MIM_LF { 1 0 10303 442 3 1 4 }")
457                .to_string(),
458            "AP242 ed2 (IS)"
459        );
460        assert_eq!(id("AUTOMOTIVE_DESIGN").to_string(), "AP214 ed? (?)");
461        assert_eq!(id("SOMETHING_ELSE").to_string(), "unrecognized schema");
462    }
463}