Skip to main content

cadmpeg_core/
dialect.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Dialect identification: what a document is, as read from its own bytes.
3//!
4//! A dialect is a named region of one format's document space, bounded only by
5//! discriminants the format itself declares. It exists whether or not cadmpeg
6//! reads it. [`DialectMatch`] is the record of one run: inspect or decode
7//! handled the file, and [`Admission`] states how.
8//!
9//! This vocabulary is about the source document. cadmpeg's own version
10//! stamp, `IR_VERSION`, is data about cadmpeg, never merged with this one and
11//! never sharing a type with it.
12//!
13//! # `DialectId` is opaque here
14//!
15//! [`DialectId`] is a namespaced pinned string at this layer and nothing more:
16//! no shared enum of every format's dialects, no version parsing, no ordering.
17//! An IR-level enum of dialects would make the shared layer depend on every
18//! codec, and orderings are codec-local where they are real at all. Outside its
19//! owning codec the id is comparable and printable; only the owner parses it.
20//!
21//! Producers use [`dialect_id!`](crate::dialect_id) for checked static ids. Wire readers use
22//! [`DialectId::parse`] for checked owned ids. A codec backs its dialects with
23//! its own enum and returns registry-generated pinned constants from it — the
24//! `*LossCode` template: enum inside, pinned string at the boundary, one
25//! construction path, closed vocabulary. Deserialization is the only other way
26//! an id comes into being, and it reconstructs an id that some producer pinned.
27
28use std::borrow::Cow;
29use std::collections::BTreeMap;
30use std::error::Error;
31use std::fmt;
32
33use crate::CodecError;
34
35#[cfg(feature = "schema")]
36use schemars::JsonSchema;
37use serde::{Deserialize, Deserializer, Serialize, Serializer};
38
39use crate::text::NonBlankString;
40
41/// A registry dialect id, for example `"rhino:archive-80"`.
42///
43/// Canonical form is `<format>:<name>`. The format contains lowercase ASCII
44/// letters and digits. The name also admits dots and hyphens, but a hyphen
45/// cannot be first or last. Outside the owning codec the validated id is an
46/// opaque label.
47///
48/// Serializes and deserializes as the plain string. Read it with
49/// [`DialectId::as_str`] or print it; there is no other access to the raw
50/// string.
51#[derive(Clone, PartialEq, Eq, Hash)]
52#[cfg_attr(feature = "schema", derive(JsonSchema), schemars(with = "String"))]
53pub struct DialectId {
54    value: Cow<'static, str>,
55    /// The separator found by admission. Both parts are non-empty ASCII.
56    namespace_len: usize,
57}
58
59/// A dialect id admitted from static storage.
60///
61/// This copyable proof lets a const initializer reject invalid text before
62/// constructing the borrowed-or-owned [`DialectId`].
63#[derive(Debug, Clone, Copy)]
64pub struct StaticDialectId {
65    value: &'static str,
66    namespace_len: usize,
67}
68
69impl StaticDialectId {
70    /// Admit a static id, returning `None` for noncanonical text.
71    #[must_use]
72    pub const fn new(id: &'static str) -> Option<Self> {
73        match dialect_separator(id) {
74            Some(namespace_len) => Some(Self {
75                value: id,
76                namespace_len,
77            }),
78            None => None,
79        }
80    }
81}
82
83impl DialectId {
84    /// Construct an id from admitted static text.
85    #[must_use]
86    pub const fn from_static(id: StaticDialectId) -> Self {
87        Self {
88            value: Cow::Borrowed(id.value),
89            namespace_len: id.namespace_len,
90        }
91    }
92
93    /// Parses and validates an owned dialect id.
94    pub fn parse(id: impl Into<String>) -> Result<Self, DialectIdError> {
95        let id = id.into();
96        match dialect_separator(&id) {
97            Some(namespace_len) => Ok(Self {
98                value: Cow::Owned(id),
99                namespace_len,
100            }),
101            None => Err(DialectIdError(id)),
102        }
103    }
104
105    /// Returns the id as a string slice.
106    #[must_use]
107    pub const fn as_str(&self) -> &str {
108        match &self.value {
109            Cow::Borrowed(id) => id,
110            Cow::Owned(id) => id.as_str(),
111        }
112    }
113
114    /// Returns the format namespace before the id's validated separator.
115    #[must_use]
116    pub fn namespace(&self) -> &str {
117        self.parts().0
118    }
119
120    pub(crate) fn local(&self) -> &str {
121        self.parts().1
122    }
123
124    fn parts(&self) -> (&str, &str) {
125        let (namespace, qualified_name) = self.as_str().split_at(self.namespace_len);
126        (namespace, &qualified_name[1..])
127    }
128}
129
130/// Construct a static dialect id whose grammar is checked during compilation.
131///
132/// ```compile_fail
133/// let _ = cadmpeg_core::dialect_id!("rhino:");
134/// ```
135#[macro_export]
136macro_rules! dialect_id {
137    ($literal:literal $(,)?) => {{
138        const STATIC_DIALECT_ID: $crate::dialect::StaticDialectId =
139            match $crate::dialect::StaticDialectId::new($literal) {
140                Some(id) => id,
141                None => panic!("invalid pinned dialect id"),
142            };
143        $crate::dialect::DialectId::from_static(STATIC_DIALECT_ID)
144    }};
145}
146
147const fn dialect_separator(id: &str) -> Option<usize> {
148    let bytes = id.as_bytes();
149    let mut index = 0;
150    let mut colon = None;
151    while index < bytes.len() {
152        if bytes[index] == b':' {
153            if colon.is_some() || index == 0 || index + 1 == bytes.len() {
154                return None;
155            }
156            colon = Some(index);
157        }
158        index += 1;
159    }
160    let Some(colon) = colon else {
161        return None;
162    };
163    index = 0;
164    while index < colon {
165        let byte = bytes[index];
166        if !byte.is_ascii_lowercase() && !byte.is_ascii_digit() {
167            return None;
168        }
169        index += 1;
170    }
171    if valid_grammar_bytes(bytes, colon + 1) {
172        Some(colon)
173    } else {
174        None
175    }
176}
177
178/// States whether `bytes[start..]` is a format-local name.
179///
180/// The name is not empty, holds lowercase ASCII letters, digits, dots and
181/// hyphens only, and neither starts nor ends with a hyphen. A colon is outside
182/// the class, so a full `<format>:<name>` id is not a name.
183const fn valid_grammar_bytes(bytes: &[u8], start: usize) -> bool {
184    if start >= bytes.len() {
185        return false;
186    }
187    let mut index = start;
188    while index < bytes.len() {
189        let byte = bytes[index];
190        if !byte.is_ascii_lowercase() && !byte.is_ascii_digit() && byte != b'-' && byte != b'.' {
191            return false;
192        }
193        index += 1;
194    }
195    bytes[start] != b'-' && bytes[bytes.len() - 1] != b'-'
196}
197
198/// A string is not a canonical dialect id.
199#[derive(Debug, Clone, PartialEq, Eq)]
200pub struct DialectIdError(String);
201
202impl fmt::Display for DialectIdError {
203    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
204        write!(
205            f,
206            "invalid dialect id {:?}: expected <format>:<name> in lowercase canonical form",
207            self.0
208        )
209    }
210}
211
212impl Error for DialectIdError {}
213
214impl fmt::Display for DialectId {
215    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
216        f.write_str(self.as_str())
217    }
218}
219
220impl fmt::Debug for DialectId {
221    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
222        write!(f, "DialectId({:?})", self.as_str())
223    }
224}
225
226impl Serialize for DialectId {
227    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
228        serializer.serialize_str(self.as_str())
229    }
230}
231
232impl<'de> Deserialize<'de> for DialectId {
233    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
234        Self::parse(String::deserialize(deserializer)?).map_err(serde::de::Error::custom)
235    }
236}
237
238/// How one format layer admitted, or refused, one document.
239///
240/// Identity and admission are orthogonal: a document can carry a registry row
241/// of its own while its bytes are read with another grammar, and a damaged
242/// frame can retain its identity while applying that row's grammar
243/// unverified. The grammar name is local to the owning [`DialectMatch`]'s
244/// format namespace.
245#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
246#[cfg_attr(feature = "schema", derive(JsonSchema))]
247#[serde(rename_all = "snake_case", deny_unknown_fields)]
248pub enum Admission {
249    /// Parsed with the strategy declared for the identified dialect.
250    Admitted,
251    /// Parsed with another declared grammar of the same format namespace.
252    ///
253    /// The codec must charge its dialect-unverified loss.
254    Unverified {
255        /// Format-local name of the grammar the parser applied.
256        using: Grammar,
257    },
258    /// Parsed without any declared grammar: the residual path.
259    ///
260    /// The codec must charge its dialect-unverified loss.
261    Residual,
262    /// Structurally identified; semantic decode refused.
263    Refused,
264}
265
266/// Format-local name of a dialect grammar, for example `sch-sw-33103`.
267///
268/// The namespace is the owning [`DialectMatch`]'s, so a grammar cannot name a
269/// foreign format by construction. The name holds the character class a
270/// [`DialectId`] admits after its separator, which excludes the separator
271/// itself: a full id has no spelling as a grammar name.
272///
273/// Serializes and deserializes as the plain string. `try_from` is the mint, so
274/// no read path reaches the field without the check. serde refuses
275/// `transparent` beside `try_from`; a newtype struct writes its inner string
276/// with no wrapper of its own, which is the same wire form.
277#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
278#[cfg_attr(feature = "schema", derive(JsonSchema))]
279#[serde(try_from = "String")]
280pub struct Grammar(String);
281
282impl Grammar {
283    /// Names the format-local half of a registry dialect id.
284    #[must_use]
285    pub fn of(dialect: &DialectId) -> Self {
286        Self(dialect.local().to_owned())
287    }
288
289    /// Parses and validates a format-local grammar name.
290    ///
291    /// The only read path into the type, through its `TryFrom<String>`.
292    pub fn parse(name: impl Into<String>) -> Result<Self, GrammarError> {
293        let name = name.into();
294        if valid_grammar_bytes(name.as_bytes(), 0) {
295            Ok(Self(name))
296        } else {
297            Err(GrammarError(name))
298        }
299    }
300
301    /// Returns the format-local grammar name.
302    #[must_use]
303    pub fn as_str(&self) -> &str {
304        &self.0
305    }
306}
307
308impl TryFrom<String> for Grammar {
309    type Error = GrammarError;
310
311    fn try_from(name: String) -> Result<Self, Self::Error> {
312        Self::parse(name)
313    }
314}
315
316/// A string is not a format-local grammar name.
317#[derive(Debug, Clone, PartialEq, Eq)]
318pub struct GrammarError(String);
319
320impl fmt::Display for GrammarError {
321    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
322        write!(
323            f,
324            "invalid grammar name {:?}: expected a format-local name in lowercase canonical form",
325            self.0
326        )
327    }
328}
329
330impl Error for GrammarError {}
331
332/// One format layer's identification of one document.
333///
334/// A document carries several format layers: `.sldprt` contains Parasolid;
335/// `.f3d`, `.ipt`, and SAT contain ACIS; NX contains Parasolid and JT. One
336/// match describes one layer.
337#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
338#[cfg_attr(feature = "schema", derive(JsonSchema))]
339#[serde(deny_unknown_fields)]
340pub struct DialectMatch {
341    /// Registry dialect id, for example `"rhino:archive-80"`.
342    dialect: DialectId,
343    /// Version fields the source declared, verbatim, under keys pinned per
344    /// codec in the registry.
345    ///
346    /// Declarations are evidence, never a control input: the dialect is what
347    /// the bytes obey, not what they declare.
348    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
349    #[serde(deserialize_with = "crate::distinct_keys::btree_map")]
350    declared: BTreeMap<NonBlankString, String>,
351    /// Instance of this format layer inside the containing document.
352    ///
353    /// `None` when the layer occurs once or has no report-local identity. This
354    /// is not source-declared evidence and therefore does not belong in
355    /// [`Self::declared`].
356    #[serde(
357        default,
358        skip_serializing_if = "Option::is_none",
359        deserialize_with = "deserialize_instance"
360    )]
361    instance: Option<String>,
362    /// How this layer was admitted.
363    admission: Admission,
364}
365
366/// Whether a format layer is the sole instance or needs its carrier identity.
367#[derive(Debug, Clone, Copy, PartialEq, Eq)]
368pub enum LayerInstance {
369    /// The host contains one layer of this format.
370    Sole,
371    /// The host contains several layers and the carrier disambiguates this one.
372    Tagged,
373}
374
375/// A report's primary format layer and any nested or carried format layers.
376///
377/// Extra layers are unique by `(format, instance)`. An extra layer for the
378/// primary format has an instance that identifies it inside the containing
379/// document. The wire names the primary explicitly, so the collection carries
380/// its complete identity without an enclosing report's format.
381///
382/// A read rejects a wire whose `extra` repeats a `(format, instance)` key, and
383/// one whose `extra` carries the primary key. The key set on the wire is the
384/// key set of the value, so no layer is silently discarded or overwritten.
385#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
386#[cfg_attr(feature = "schema", derive(JsonSchema))]
387pub struct DialectLayers {
388    primary: DialectMatch,
389    extra: Vec<DialectMatch>,
390}
391
392#[derive(Deserialize)]
393#[serde(deny_unknown_fields)]
394struct DialectLayersWire {
395    primary: DialectMatch,
396    extra: Vec<DialectMatch>,
397}
398
399impl<'de> Deserialize<'de> for DialectLayers {
400    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
401        let wire = DialectLayersWire::deserialize(deserializer)?;
402        let mut layers = Self::of(wire.primary);
403        for layer in wire.extra {
404            layers.insert(layer).map_err(|rejected| {
405                <D::Error as serde::de::Error>::custom(format!(
406                    "duplicate dialect layer key: {rejected:?}"
407                ))
408            })?;
409        }
410        Ok(layers)
411    }
412}
413
414/// Failure to insert a unique dialect layer.
415#[derive(Debug, Clone, PartialEq, Eq)]
416pub enum DialectLayerError {
417    /// The existing layer has the same format and instance.
418    Duplicate(DialectMatch),
419}
420
421impl fmt::Display for DialectLayerError {
422    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
423        match self {
424            Self::Duplicate(layer) => write!(formatter, "duplicate dialect layer key: {layer:?}"),
425        }
426    }
427}
428
429impl Error for DialectLayerError {}
430
431impl From<DialectLayerError> for CodecError {
432    fn from(error: DialectLayerError) -> Self {
433        match error {
434            DialectLayerError::Duplicate(layer) => {
435                Self::malformed(format_args!("duplicate dialect layer key: {layer:?}"))
436            }
437        }
438    }
439}
440
441impl DialectLayers {
442    /// Constructs dialect layers with one primary and no extra layers.
443    #[must_use]
444    pub fn of(primary: DialectMatch) -> Self {
445        Self {
446            primary,
447            extra: Vec::new(),
448        }
449    }
450
451    /// Inserts a layer keyed by `(format, instance)`.
452    ///
453    /// The first layer for a key remains authoritative. A duplicate is
454    /// returned unchanged so its producer can report the collision.
455    pub fn insert(&mut self, layer: DialectMatch) -> Result<(), DialectLayerError> {
456        if Self::same_key(&self.primary, &layer)
457            || self
458                .extra
459                .iter()
460                .any(|existing| Self::same_key(existing, &layer))
461        {
462            return Err(DialectLayerError::Duplicate(layer));
463        }
464        self.extra.push(layer);
465        Ok(())
466    }
467
468    /// Adds a layer, returning it unchanged when its key is occupied.
469    pub fn with(mut self, layer: DialectMatch) -> Result<Self, DialectLayerError> {
470        self.insert(layer)?;
471        Ok(self)
472    }
473
474    fn same_key(existing: &DialectMatch, layer: &DialectMatch) -> bool {
475        existing.format() == layer.format() && existing.instance == layer.instance
476    }
477
478    /// Returns the report's primary format layer.
479    #[must_use]
480    pub fn primary(&self) -> &DialectMatch {
481        &self.primary
482    }
483
484    /// Iterates over the primary layer followed by every extra layer.
485    pub fn iter(&self) -> impl Iterator<Item = &DialectMatch> {
486        std::iter::once(&self.primary).chain(&self.extra)
487    }
488}
489
490mod format_identity_sealed {
491    pub trait Sealed {}
492}
493
494/// A classified payload whose format namespace is intrinsic to the payload.
495///
496/// This trait is sealed. The supported payloads are [`DialectId`],
497/// [`DialectMatch`], and [`DialectLayers`].
498pub trait FormatIdentityPayload: format_identity_sealed::Sealed {
499    /// Returns the payload's authoritative format namespace.
500    fn format(&self) -> &str;
501}
502
503impl format_identity_sealed::Sealed for DialectId {}
504
505impl FormatIdentityPayload for DialectId {
506    fn format(&self) -> &str {
507        self.namespace()
508    }
509}
510
511impl format_identity_sealed::Sealed for DialectMatch {}
512
513impl FormatIdentityPayload for DialectMatch {
514    fn format(&self) -> &str {
515        self.format()
516    }
517}
518
519impl format_identity_sealed::Sealed for DialectLayers {}
520
521impl FormatIdentityPayload for DialectLayers {
522    fn format(&self) -> &str {
523        self.primary().format()
524    }
525}
526
527/// A format identity that is either classified by a typed payload or retains
528/// only its known format.
529///
530/// Classified state stores no second format string. The payload is the one
531/// author of its namespace, so the format is on the wire exactly once in
532/// either state.
533#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
534#[cfg_attr(feature = "schema", derive(JsonSchema))]
535#[serde(tag = "classification", rename_all = "snake_case", deny_unknown_fields)]
536pub enum FormatIdentity<T> {
537    /// The payload carries the complete classified identity.
538    Classified {
539        /// Classified payload, whose namespace is the format.
540        dialects: T,
541    },
542    /// The format is known but no classification payload exists.
543    Unclassified {
544        /// Registry format namespace.
545        format: String,
546    },
547}
548
549impl<T: FormatIdentityPayload> FormatIdentity<T> {
550    /// Constructs an identity whose format comes from its classified payload.
551    #[must_use]
552    pub const fn classified(payload: T) -> Self {
553        Self::Classified { dialects: payload }
554    }
555
556    /// Constructs an identity for a known format without classification.
557    #[must_use]
558    pub fn unclassified(format: impl Into<String>) -> Self {
559        Self::Unclassified {
560            format: format.into(),
561        }
562    }
563
564    /// Returns the authoritative format id.
565    #[must_use]
566    pub fn format(&self) -> &str {
567        match self {
568            Self::Classified { dialects } => dialects.format(),
569            Self::Unclassified { format } => format,
570        }
571    }
572
573    /// Returns the classified payload, when present.
574    #[must_use]
575    pub const fn classified_payload(&self) -> Option<&T> {
576        match self {
577            Self::Classified { dialects } => Some(dialects),
578            Self::Unclassified { .. } => None,
579        }
580    }
581}
582
583impl DialectMatch {
584    fn with_admission(dialect: DialectId, admission: Admission) -> Self {
585        Self {
586            dialect,
587            declared: BTreeMap::new(),
588            instance: None,
589            admission,
590        }
591    }
592
593    /// Constructs a layer parsed with its identified dialect's grammar.
594    #[must_use]
595    pub fn admitted(dialect: DialectId) -> Self {
596        Self::with_admission(dialect, Admission::Admitted)
597    }
598
599    /// Constructs a layer parsed unverified with another declared grammar.
600    ///
601    /// `using` supplies its format-local name; the grammar's namespace is the
602    /// layer's own.
603    #[must_use]
604    pub fn unverified(dialect: DialectId, using: Grammar) -> Self {
605        Self::with_admission(dialect, Admission::Unverified { using })
606    }
607
608    /// Constructs a layer parsed on the residual path without a declared grammar.
609    #[must_use]
610    pub fn residual(dialect: DialectId) -> Self {
611        Self::with_admission(dialect, Admission::Residual)
612    }
613
614    /// Constructs a structurally identified layer whose decode was refused.
615    #[must_use]
616    pub fn refused(dialect: DialectId) -> Self {
617        Self::with_admission(dialect, Admission::Refused)
618    }
619
620    /// Attaches source-declared version fields before the match enters a report.
621    #[must_use]
622    pub fn with_declared(mut self, declared: BTreeMap<NonBlankString, String>) -> Self {
623        self.declared = declared;
624        self
625    }
626
627    /// Adds one declaration, replacing an earlier value for the same key.
628    #[must_use]
629    pub fn with_declared_entry(mut self, key: NonBlankString, value: &str) -> Self {
630        // discarded-value: a later declaration replaces an earlier one.
631        let _ = self.declared.insert(key, value.to_owned());
632        self
633    }
634
635    /// Attaches a report-local layer instance before the match enters a report.
636    #[must_use]
637    pub fn with_instance(mut self, instance: impl Into<String>) -> Self {
638        self.instance = Some(instance.into());
639        self
640    }
641
642    /// Returns the classified format layer.
643    #[must_use]
644    pub fn format(&self) -> &str {
645        self.dialect.namespace()
646    }
647
648    /// Returns the registry dialect identity.
649    #[must_use]
650    pub fn dialect(&self) -> &DialectId {
651        &self.dialect
652    }
653
654    /// Returns the source-declared version fields.
655    #[must_use]
656    pub fn declared(&self) -> &BTreeMap<NonBlankString, String> {
657        &self.declared
658    }
659
660    /// Returns the report-local layer instance.
661    #[must_use]
662    pub fn instance(&self) -> Option<&str> {
663        self.instance.as_deref()
664    }
665
666    /// Returns how this layer was admitted.
667    #[must_use]
668    pub fn admission(&self) -> &Admission {
669        &self.admission
670    }
671
672    /// Returns the full registry id of the grammar applied, when this layer
673    /// was parsed unverified with a declared grammar.
674    #[must_use]
675    pub fn using(&self) -> Option<DialectId> {
676        match &self.admission {
677            Admission::Unverified { using } => Some(self.grammar_id(using)),
678            Admission::Admitted | Admission::Residual | Admission::Refused => None,
679        }
680    }
681
682    /// Return the full grammar identity in this match’s format namespace.
683    #[must_use]
684    pub fn grammar_id(&self, grammar: &Grammar) -> DialectId {
685        let namespace = self.format();
686        DialectId {
687            value: Cow::Owned(format!("{namespace}:{}", grammar.as_str())),
688            namespace_len: namespace.len(),
689        }
690    }
691}
692
693#[cfg(test)]
694mod tests {
695    #![allow(clippy::unwrap_used)]
696
697    use std::collections::BTreeMap;
698
699    use super::{Admission, DialectId, DialectLayers, DialectMatch, Grammar, StaticDialectId};
700
701    #[derive(serde::Deserialize)]
702    #[serde(deny_unknown_fields)]
703    struct DialectIdConformance {
704        valid: Vec<String>,
705        invalid: Vec<String>,
706    }
707
708    fn layer(format: &str) -> DialectMatch {
709        DialectMatch::admitted(DialectId::parse(format!("{format}:known")).unwrap())
710    }
711
712    #[test]
713    fn a_pinned_id_prints_and_serializes_as_the_plain_string() {
714        let id = crate::dialect_id!("rhino:archive-80");
715
716        assert_eq!(id.to_string(), "rhino:archive-80");
717        assert_eq!(id.as_str(), "rhino:archive-80");
718        assert_eq!(serde_json::to_string(&id).unwrap(), "\"rhino:archive-80\"");
719        assert_eq!(
720            serde_json::from_str::<DialectId>("\"rhino:archive-80\"").unwrap(),
721            id
722        );
723    }
724
725    #[test]
726    fn static_dialect_admission_returns_none_for_invalid_runtime_text() {
727        for text in [
728            "",
729            "rhino",
730            "rhino:",
731            ":known",
732            "Rhino:known",
733            "a:b:c",
734            "a:é",
735        ] {
736            assert!(StaticDialectId::new(text).is_none(), "{text:?}");
737        }
738        let admitted = DialectId::from_static(StaticDialectId::new("rhino:archive-80").unwrap());
739        assert_eq!(admitted.namespace(), "rhino");
740        assert_eq!(admitted.local(), "archive-80");
741        assert_eq!(admitted, DialectId::parse("rhino:archive-80").unwrap());
742    }
743
744    #[test]
745    fn dialect_id_deserialization_rejects_noncanonical_strings() {
746        for id in [
747            "rhino",
748            ":archive-80",
749            "rhino:",
750            "Rhino:archive-80",
751            "rhino:archive_80",
752            "rhino:-archive-80",
753        ] {
754            serde_json::from_value::<DialectId>(serde_json::json!(id))
755                .expect_err("a malformed dialect id must be rejected");
756        }
757    }
758
759    #[test]
760    fn dialect_id_matches_the_shared_conformance_corpus() {
761        let cases: DialectIdConformance =
762            toml::from_str(include_str!("../../../docs/dialect-id-conformance.toml")).unwrap();
763        for id in cases.valid {
764            let admitted =
765                DialectId::parse(id.clone()).unwrap_or_else(|_| panic!("valid dialect id {id:?}"));
766            assert_eq!(format!("{}:{}", admitted.namespace(), admitted.local()), id);
767        }
768        for id in cases.invalid {
769            assert!(
770                DialectId::parse(id.clone()).is_err(),
771                "invalid dialect id {id:?}"
772            );
773        }
774    }
775
776    #[test]
777    fn an_admitted_match_serializes_its_identity() {
778        let admitted = DialectMatch {
779            dialect: crate::dialect_id!("rhino:archive-80"),
780            declared: BTreeMap::new(),
781            instance: None,
782            admission: Admission::Admitted,
783        };
784
785        assert_eq!(
786            serde_json::to_string(&admitted).unwrap(),
787            "{\"dialect\":\"rhino:archive-80\",\"admission\":\"admitted\"}"
788        );
789    }
790
791    #[test]
792    fn a_dialect_match_wire_states_its_format_once_through_its_dialect() {
793        let restated = serde_json::json!({
794            "format": "rhino",
795            "dialect": "rhino:archive-80",
796            "admission": "admitted",
797        });
798
799        let error = serde_json::from_value::<DialectMatch>(restated)
800            .expect_err("the format is read from the dialect, so the wire carries no format key");
801        assert!(
802            error.to_string().contains("unknown field `format`"),
803            "{error}"
804        );
805        assert_eq!(
806            DialectMatch::admitted(crate::dialect_id!("rhino:archive-80")).format(),
807            "rhino"
808        );
809    }
810
811    #[test]
812    fn residual_constructor_records_the_absence_of_a_declared_grammar() {
813        let residual = DialectMatch::residual(crate::dialect_id!("rhino:unknown"));
814
815        assert_eq!(residual.admission(), &Admission::Residual);
816        assert_eq!(residual.using(), None);
817        assert_eq!(
818            serde_json::to_string(&residual).unwrap(),
819            "{\"dialect\":\"rhino:unknown\",\"admission\":\"residual\"}"
820        );
821    }
822
823    #[test]
824    fn an_unverified_admission_names_the_grammar_in_use_by_full_id() {
825        let unverified = DialectMatch::unverified(
826            crate::dialect_id!("acis:save-format-217"),
827            Grammar::of(&crate::dialect_id!("acis:save-format-218")),
828        );
829
830        assert_eq!(
831            unverified.admission(),
832            &Admission::Unverified {
833                using: Grammar::of(&crate::dialect_id!("acis:save-format-218")),
834            }
835        );
836        assert_eq!(
837            unverified.using(),
838            Some(crate::dialect_id!("acis:save-format-218"))
839        );
840        let serialized = serde_json::to_string(&unverified).unwrap();
841        assert_eq!(
842            serialized,
843            "{\"dialect\":\"acis:save-format-217\",\"admission\":{\"unverified\":{\"using\":\"save-format-218\"}}}"
844        );
845        assert_eq!(
846            serde_json::from_str::<DialectMatch>(&serialized).unwrap(),
847            unverified
848        );
849    }
850
851    #[test]
852    fn a_self_named_unverified_grammar_remains_opaque() {
853        let self_named = serde_json::json!({
854            "dialect": "rhino:unknown",
855            "admission": {
856                "unverified": { "using": "unknown" }
857            },
858        });
859        let matched = serde_json::from_value::<DialectMatch>(self_named)
860            .expect("core does not infer grammar semantics from an opaque dialect name");
861        assert_eq!(
862            matched.admission(),
863            &Admission::Unverified {
864                using: Grammar::of(&crate::dialect_id!("rhino:unknown")),
865            }
866        );
867    }
868
869    #[test]
870    fn a_grammar_name_has_no_spelling_for_a_foreign_namespace() {
871        let malformed = serde_json::json!({
872            "dialect": "rhino:unknown",
873            "admission": {
874                "unverified": { "using": "step:ap242-e3" }
875            },
876        });
877        let error = serde_json::from_value::<DialectMatch>(malformed)
878            .expect_err("a grammar substitute belongs to the classified format layer");
879        assert!(
880            error
881                .to_string()
882                .contains("invalid grammar name \"step:ap242-e3\""),
883            "{error}"
884        );
885    }
886
887    #[test]
888    fn grammar_parsing_rejects_a_name_outside_the_format_local_class() {
889        for name in [
890            "",
891            "rhino:archive-80",
892            "Archive-80",
893            "archive_80",
894            "-archive",
895        ] {
896            assert!(
897                Grammar::parse(name).is_err(),
898                "invalid grammar name {name:?}"
899            );
900        }
901        assert_eq!(
902            Grammar::parse("save-format-218").unwrap(),
903            Grammar::of(&crate::dialect_id!("acis:save-format-218"))
904        );
905    }
906
907    #[test]
908    fn identity_does_not_encode_whether_an_unverified_path_used_a_grammar() {
909        let dialect = crate::dialect_id!("rhino:archive-80");
910        let without_grammar = DialectMatch::residual(dialect.clone());
911        assert_eq!(without_grammar.admission(), &Admission::Residual);
912
913        let self_named = DialectMatch::unverified(dialect.clone(), Grammar::of(&dialect));
914        assert_eq!(
915            self_named.admission(),
916            &Admission::Unverified {
917                using: Grammar::of(&crate::dialect_id!("rhino:archive-80")),
918            }
919        );
920        assert_eq!(self_named.using(), Some(dialect));
921    }
922
923    #[test]
924    fn dialect_layers_accept_a_same_format_extra_with_an_instance() {
925        let member = layer("rhino").with_instance("components/member.3dm");
926        let layers = DialectLayers::of(layer("rhino"))
927            .with(member.clone())
928            .expect("distinct dialect layer keys");
929
930        let serialized = serde_json::to_value(&layers).unwrap();
931        assert_eq!(
932            serde_json::from_value::<DialectLayers>(serialized).unwrap(),
933            layers
934        );
935        assert_eq!(layers.extra, [member]);
936    }
937
938    #[test]
939    fn dialect_layers_insert_keeps_the_first_extra_layer_for_a_key() {
940        let first = layer("acis").with_instance("body");
941        let replacement =
942            DialectMatch::residual(crate::dialect_id!("acis:other")).with_instance("body");
943        let mut layers = DialectLayers::of(layer("rhino"))
944            .with(first.clone())
945            .expect("distinct dialect layer keys");
946
947        assert_eq!(
948            layers.insert(replacement.clone()),
949            Err(super::DialectLayerError::Duplicate(replacement))
950        );
951        assert_eq!(layers.extra, [first]);
952    }
953
954    #[test]
955    fn dialect_layers_insert_keeps_the_primary_on_its_own_key() {
956        let replacement = DialectMatch::residual(crate::dialect_id!("rhino:other"));
957        let mut layers = DialectLayers::of(layer("rhino"))
958            .with(layer("acis"))
959            .expect("distinct dialect layer keys");
960
961        assert_eq!(
962            layers.insert(replacement.clone()),
963            Err(super::DialectLayerError::Duplicate(replacement))
964        );
965        assert_eq!(layers.primary(), &layer("rhino"));
966        assert_eq!(layers.extra, [layer("acis")]);
967    }
968
969    #[test]
970    fn dialect_layers_builder_returns_the_colliding_layer() {
971        let first = layer("acis").with_instance("body");
972        let replacement =
973            DialectMatch::residual(crate::dialect_id!("acis:other")).with_instance("body");
974        let layers = DialectLayers::of(layer("rhino"))
975            .with(first)
976            .expect("distinct dialect layer keys");
977        assert_eq!(
978            layers.with(replacement.clone()),
979            Err(super::DialectLayerError::Duplicate(replacement))
980        );
981    }
982
983    #[test]
984    fn dialect_layers_keep_same_format_extras_with_distinct_instances() {
985        let anonymous = layer("acis");
986        let named = layer("acis").with_instance("body");
987        let mut layers = DialectLayers::of(layer("rhino"));
988
989        assert_eq!(layers.insert(anonymous.clone()), Ok(()));
990        assert_eq!(layers.insert(named.clone()), Ok(()));
991        assert_eq!(layers.extra, [anonymous, named]);
992    }
993
994    #[test]
995    fn dialect_layers_deserialization_rejects_a_repeated_key() {
996        let serialized = serde_json::json!({
997            "primary": layer("rhino"),
998            "extra": [layer("acis"), DialectMatch::residual(crate::dialect_id!("acis:other"))],
999        });
1000
1001        let error = serde_json::from_value::<DialectLayers>(serialized)
1002            .expect_err("a repeated extra layer key is a read error");
1003        assert!(
1004            error.to_string().contains("duplicate dialect layer key"),
1005            "{error}"
1006        );
1007    }
1008
1009    #[test]
1010    fn dialect_layers_deserialization_rejects_an_extra_on_the_primary_key() {
1011        let serialized = serde_json::json!({
1012            "primary": layer("rhino"),
1013            "extra": [DialectMatch::residual(crate::dialect_id!("rhino:other"))],
1014        });
1015
1016        let error = serde_json::from_value::<DialectLayers>(serialized)
1017            .expect_err("an extra layer on the primary key is a read error");
1018        assert!(
1019            error.to_string().contains("duplicate dialect layer key"),
1020            "{error}"
1021        );
1022    }
1023
1024    #[test]
1025    fn dialect_layers_serialize_with_an_explicit_primary() {
1026        let layers = DialectLayers::of(layer("rhino"))
1027            .with(layer("acis"))
1028            .expect("distinct dialect layer keys");
1029        let serialized = serde_json::to_value(&layers).unwrap();
1030
1031        assert_eq!(
1032            serialized,
1033            serde_json::json!({
1034                "primary": layer("rhino"),
1035                "extra": [layer("acis")],
1036            })
1037        );
1038        assert_eq!(
1039            serde_json::from_value::<DialectLayers>(serialized).unwrap(),
1040            layers
1041        );
1042        assert_eq!(
1043            layers.iter().map(DialectMatch::format).collect::<Vec<_>>(),
1044            ["rhino", "acis"]
1045        );
1046    }
1047}
1048
1049// Each optional key below names itself in whatever it refuses.
1050crate::named_optional_field!(deserialize_instance, String, "instance");