Skip to main content

rtemis_a3/
issue.rs

1//! Validation issues: stable codes, RFC 6901 paths, and human messages.
2//!
3//! The contract is defined in `spec/error-codes.md` and `spec/error-paths.md`
4//! at the repository root, and is shared by all five A3 implementations. The
5//! `code` and `path` of every issue are contractual; the `message` is not and
6//! may be reworded freely.
7
8use std::fmt;
9
10use serde::Serialize;
11
12// ---------------------------------------------------------------------------
13// Codes
14// ---------------------------------------------------------------------------
15
16/// Declare every issue code once, deriving the enum, its wire string, its
17/// validation stage, and the full list from a single table.
18///
19/// A `macro_rules!` macro is Rust's pattern-based code generator. Writing the
20/// table once means a new code cannot be added to the enum without also giving
21/// it a stage and a wire string — the three can never drift apart.
22macro_rules! issue_codes {
23    ($( $variant:ident => $wire:literal, $stage:literal );* $(;)?) => {
24        /// A stable, machine-readable validation issue code.
25        ///
26        /// Codes are never renumbered and never reused. See
27        /// `spec/error-codes.md` for the full registry.
28        #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
29        pub enum A3IssueCode {
30            $(
31                #[doc = $wire]
32                $variant,
33            )*
34        }
35
36        impl A3IssueCode {
37            /// The code's stable wire string, e.g. `"A3E_SEQ_TOO_SHORT"`.
38            pub fn code(self) -> &'static str {
39                match self {
40                    $( A3IssueCode::$variant => $wire, )*
41                }
42            }
43
44            /// The validation stage this code belongs to (1–4).
45            pub fn stage(self) -> u8 {
46                match self {
47                    $( A3IssueCode::$variant => $stage, )*
48                }
49            }
50
51            /// Every code in the registry, for coverage checks.
52            pub const ALL: &'static [A3IssueCode] = &[ $( A3IssueCode::$variant, )* ];
53        }
54    };
55}
56
57issue_codes! {
58    // Stage 1 — Envelope
59    DocNotObject               => "A3E_DOC_NOT_OBJECT",                1;
60    EnvelopeSchemaMissing      => "A3E_ENVELOPE_SCHEMA_MISSING",       1;
61    EnvelopeSchemaMismatch     => "A3E_ENVELOPE_SCHEMA_MISMATCH",      1;
62    EnvelopeVersionMissing     => "A3E_ENVELOPE_VERSION_MISSING",      1;
63    EnvelopeVersionMismatch    => "A3E_ENVELOPE_VERSION_MISMATCH",     1;
64
65    // Stage 2 — Structural
66    UnknownField               => "A3E_UNKNOWN_FIELD",                 2;
67    SeqMissing                 => "A3E_SEQ_MISSING",                   2;
68    SeqNotString               => "A3E_SEQ_NOT_STRING",                2;
69    SeqTooShort                => "A3E_SEQ_TOO_SHORT",                 2;
70    SeqCharset                 => "A3E_SEQ_CHARSET",                   2;
71    AnnotationsNotObject       => "A3E_ANNOTATIONS_NOT_OBJECT",        2;
72    MetadataNotObject          => "A3E_METADATA_NOT_OBJECT",           2;
73    MetadataFieldNotString     => "A3E_METADATA_FIELD_NOT_STRING",     2;
74    FamilyNotObject            => "A3E_FAMILY_NOT_OBJECT",             2;
75    VariantListNotArray        => "A3E_VARIANT_LIST_NOT_ARRAY",        2;
76    NameEmpty                  => "A3E_NAME_EMPTY",                    2;
77    EntryNotObject             => "A3E_ENTRY_NOT_OBJECT",              2;
78    EntryTypeNotString         => "A3E_ENTRY_TYPE_NOT_STRING",         2;
79    IndexMissing               => "A3E_INDEX_MISSING",                 2;
80    IndexNotArray              => "A3E_INDEX_NOT_ARRAY",               2;
81    IndexElementType           => "A3E_INDEX_ELEMENT_TYPE",            2;
82    IndexMixed                 => "A3E_INDEX_MIXED",                   2;
83    RangeArity                 => "A3E_RANGE_ARITY",                   2;
84    RangeEndpointNotInteger    => "A3E_RANGE_ENDPOINT_NOT_INTEGER",    2;
85    VariantNotObject           => "A3E_VARIANT_NOT_OBJECT",            2;
86    VariantPositionMissing     => "A3E_VARIANT_POSITION_MISSING",      2;
87    VariantPositionNotInteger  => "A3E_VARIANT_POSITION_NOT_INTEGER",  2;
88
89    // Stage 3 — Intra-field
90    PosNotPositive             => "A3E_POS_NOT_POSITIVE",              3;
91    PosDuplicate               => "A3E_POS_DUPLICATE",                 3;
92    RangeEndpointNotPositive   => "A3E_RANGE_ENDPOINT_NOT_POSITIVE",   3;
93    RangeOrder                 => "A3E_RANGE_ORDER",                   3;
94    RangeOverlap               => "A3E_RANGE_OVERLAP",                 3;
95    VariantPositionNotPositive => "A3E_VARIANT_POSITION_NOT_POSITIVE", 3;
96
97    // Stage 4 — Contextual
98    PosOutOfBounds             => "A3E_POS_OUT_OF_BOUNDS",             4;
99    RangeOutOfBounds           => "A3E_RANGE_OUT_OF_BOUNDS",           4;
100    VariantOutOfBounds         => "A3E_VARIANT_OUT_OF_BOUNDS",         4;
101    VariantResidueMismatch     => "A3E_VARIANT_RESIDUE_MISMATCH",      4;
102}
103
104impl fmt::Display for A3IssueCode {
105    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
106        f.write_str(self.code())
107    }
108}
109
110impl Serialize for A3IssueCode {
111    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
112        serializer.serialize_str(self.code())
113    }
114}
115
116// ---------------------------------------------------------------------------
117// Issues
118// ---------------------------------------------------------------------------
119
120/// A single validation issue.
121///
122/// `code` and `path` are contractual and identical across all five A3
123/// implementations. `message` is for humans and may differ between them.
124#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
125pub struct A3Issue {
126    /// Stable machine-readable code.
127    pub code: A3IssueCode,
128    /// RFC 6901 JSON Pointer to the offending value.
129    pub path: String,
130    /// Human-readable explanation. Not contractual.
131    pub message: String,
132}
133
134impl A3Issue {
135    /// Build an issue. `path` is taken as an already-escaped JSON Pointer.
136    pub fn new(code: A3IssueCode, path: impl Into<String>, message: impl Into<String>) -> Self {
137        A3Issue {
138            code,
139            path: path.into(),
140            message: message.into(),
141        }
142    }
143
144    /// The validation stage this issue belongs to (1–4).
145    pub fn stage(&self) -> u8 {
146        self.code.stage()
147    }
148}
149
150impl fmt::Display for A3Issue {
151    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
152        // The empty pointer denotes the whole document; naming it "/" would be
153        // wrong (that is the member named ""), so spell it out instead.
154        let where_ = if self.path.is_empty() {
155            "<document>"
156        } else {
157            &self.path
158        };
159        write!(f, "[{}] {}: {}", self.code.code(), where_, self.message)
160    }
161}
162
163// ---------------------------------------------------------------------------
164// JSON Pointer construction
165// ---------------------------------------------------------------------------
166
167/// Escape one reference token for inclusion in a JSON Pointer.
168///
169/// RFC 6901 escapes exactly two characters. `~` must be replaced before `/`,
170/// or a key containing `/` would come out as `~01` instead of `~1`.
171pub fn escape_token(token: &str) -> String {
172    token.replace('~', "~0").replace('/', "~1")
173}
174
175/// Append one reference token to a JSON Pointer, escaping it.
176///
177/// `pointer("/annotations/site", "a/b")` → `"/annotations/site/a~1b"`.
178pub fn pointer(base: &str, token: &str) -> String {
179    format!("{base}/{}", escape_token(token))
180}
181
182/// Append a 0-based array subscript to a JSON Pointer.
183///
184/// Subscripts are decimal and need no escaping.
185pub fn pointer_index(base: &str, index: usize) -> String {
186    format!("{base}/{index}")
187}
188
189// ---------------------------------------------------------------------------
190// Tests
191// ---------------------------------------------------------------------------
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    #[test]
198    fn codes_are_unique() {
199        let mut seen = std::collections::HashSet::new();
200        for c in A3IssueCode::ALL {
201            assert!(seen.insert(c.code()), "duplicate code: {}", c.code());
202        }
203        assert_eq!(
204            seen.len(),
205            37,
206            "registry size changed — update spec/error-codes.md"
207        );
208    }
209
210    #[test]
211    fn every_code_has_a_valid_stage() {
212        for c in A3IssueCode::ALL {
213            assert!(
214                (1..=4).contains(&c.stage()),
215                "{} has stage {}",
216                c.code(),
217                c.stage()
218            );
219        }
220    }
221
222    #[test]
223    fn escapes_tilde_before_slash() {
224        // "~1" must survive as "~01", not be re-read as an escaped slash.
225        assert_eq!(escape_token("a~1b"), "a~01b");
226        assert_eq!(escape_token("a/b"), "a~1b");
227        assert_eq!(escape_token("a~b"), "a~0b");
228        assert_eq!(escape_token("a.b"), "a.b");
229        assert_eq!(escape_token("$schema"), "$schema");
230    }
231
232    #[test]
233    fn builds_pointers() {
234        assert_eq!(
235            pointer("/annotations/site", "a/b"),
236            "/annotations/site/a~1b"
237        );
238        assert_eq!(pointer("", "$schema"), "/$schema");
239        // A name that is the empty string yields a trailing empty token.
240        assert_eq!(pointer("/annotations/site", ""), "/annotations/site/");
241        assert_eq!(
242            pointer_index("/annotations/variant", 3),
243            "/annotations/variant/3"
244        );
245    }
246}