Skip to main content

regit_identifiers/
mic.rs

1// Copyright 2026 Regit.io — Nicolas Koenig
2// SPDX-License-Identifier: Apache-2.0
3
4//! MIC — Market Identifier Code (ISO 10383).
5//!
6//! A MIC names a market — an exchange, a multilateral trading facility, or
7//! another trading venue — rather than a security. It is exactly 4
8//! characters with no internal segmentation:
9//!
10//! ```text
11//!   X N A S
12//!   │ └─┴─┘
13//!   │   │
14//!   │   └─── market suffix  [1..4]   three characters [A-Z0-9]
15//!   └─────── leading letter [0]      one upper-case letter [A-Z]
16//! ```
17//!
18//! - The **leading character** is always an upper-case ASCII letter.
19//! - The remaining **three characters** are each an upper-case letter or a
20//!   digit.
21//! - A MIC carries **no check digit**: there is nothing to recompute. The
22//!   four characters are either well-formed or they are not.
23//!
24//! ISO 10383 also distinguishes an *operating* MIC, which identifies a market
25//! operator, from a *segment* MIC, which names a sub-market and references its
26//! operating MIC.
27//!
28//! Structural validity is necessary but not sufficient: `ZZZZ` is well-formed
29//! yet identifies no real market. True validity is membership in the published
30//! ISO 10383 registry. With the default `mic-registry` feature enabled this
31//! crate embeds a snapshot of that registry; [`Mic::lookup`],
32//! [`Mic::is_registered`], and [`Mic::parse_registered`] consult it, and the
33//! [`MicEntry`] / [`MicStatus`] types are re-exported here.
34//!
35//! [`Mic::parse`] enforces the structural rules; [`Mic::parse_registered`]
36//! additionally requires that the code be present in the embedded registry.
37//!
38//! # References
39//!
40//! - ISO 10383, *Securities and related financial instruments — Codes for
41//!   exchanges and market identification (MIC)*.
42
43use crate::errors::ValidationError;
44
45#[cfg(feature = "mic-registry")]
46pub use crate::mic_registry::{MicEntry, MicStatus};
47
48/// A validated Market Identifier Code (ISO 10383).
49///
50/// A `Mic` can only be created by [`Mic::parse`] (or the explicitly unchecked
51/// [`Mic::from_bytes_unchecked`]), so a value of this type is a proof that the
52/// four characters are structurally a valid MIC: a leading upper-case letter
53/// followed by three upper-case alphanumeric characters. It stores the
54/// identifier inline as `[u8; 4]`, is `Copy`, and allocates nothing.
55///
56/// Structural validity does not imply the market exists; use
57/// [`Mic::is_registered`] or [`Mic::parse_registered`] to additionally check
58/// the embedded ISO 10383 registry.
59///
60/// # Examples
61///
62/// ```
63/// use regit_identifiers::Mic;
64///
65/// let mic = Mic::parse("XNAS").unwrap();
66/// assert_eq!(mic.as_str(), "XNAS");
67/// assert_eq!(mic.suffix(), "NAS");
68/// ```
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
70pub struct Mic {
71    /// The 4 validated ASCII bytes of the identifier.
72    bytes: [u8; Self::LENGTH],
73}
74
75impl Mic {
76    /// The number of characters in a MIC.
77    pub const LENGTH: usize = 4;
78
79    /// Parses and validates a MIC.
80    ///
81    /// Validation is strict and, in order: the input must be exactly 4
82    /// characters; the first character must be an ASCII upper-case letter; and
83    /// each of the remaining three characters must be an ASCII digit or
84    /// upper-case letter. A MIC has no check digit, so there is nothing
85    /// further to verify.
86    ///
87    /// This checks structure only — it does not consult the ISO 10383
88    /// registry. Use [`Mic::parse_registered`] to additionally require that
89    /// the code names a real market.
90    ///
91    /// # Errors
92    ///
93    /// - [`ValidationError::WrongLength`] if the input is not 4 characters.
94    /// - [`ValidationError::InvalidCharacter`] if a character falls outside
95    ///   the set its position allows (this also rejects lower-case input and
96    ///   any non-ASCII character).
97    ///
98    /// # Examples
99    ///
100    /// ```
101    /// use regit_identifiers::Mic;
102    /// use regit_identifiers::errors::ValidationError;
103    ///
104    /// assert!(Mic::parse("XLON").is_ok());
105    ///
106    /// // The leading character must be a letter, not a digit.
107    /// assert_eq!(
108    ///     Mic::parse("1NAS"),
109    ///     Err(ValidationError::InvalidCharacter { position: 1, found: '1' }),
110    /// );
111    /// ```
112    pub fn parse(s: &str) -> Result<Self, ValidationError> {
113        // A MIC is exactly 4 characters.
114        let found = s.chars().count();
115        if found != Self::LENGTH {
116            return Err(ValidationError::WrongLength {
117                expected: Self::LENGTH,
118                found,
119            });
120        }
121        // Per-position character set: [0] is a letter, [1..4] are [A-Z0-9].
122        // A non-ASCII character fails both predicates and is rejected here.
123        for (i, ch) in s.chars().enumerate() {
124            let legal = if i == 0 {
125                ch.is_ascii_uppercase()
126            } else {
127                ch.is_ascii_digit() || ch.is_ascii_uppercase()
128            };
129            if !legal {
130                return Err(ValidationError::InvalidCharacter {
131                    position: i + 1,
132                    found: ch,
133                });
134            }
135        }
136        // Every character is ASCII, so the string is exactly 4 ASCII bytes.
137        let mut bytes = [0u8; Self::LENGTH];
138        bytes.copy_from_slice(s.as_bytes());
139        Ok(Self { bytes })
140    }
141
142    /// Validates a MIC without constructing one.
143    ///
144    /// Equivalent to `Mic::parse(s).map(|_| ())`; use it when only the verdict
145    /// is needed.
146    ///
147    /// # Errors
148    ///
149    /// Returns the same [`ValidationError`] variants as [`Mic::parse`].
150    ///
151    /// # Examples
152    ///
153    /// ```
154    /// use regit_identifiers::Mic;
155    ///
156    /// assert!(Mic::validate("XPAR").is_ok());
157    /// assert!(Mic::validate("xpar").is_err());
158    /// ```
159    pub fn validate(s: &str) -> Result<(), ValidationError> {
160        Self::parse(s).map(|_| ())
161    }
162
163    /// Parses a MIC and requires it to be in the ISO 10383 registry.
164    ///
165    /// First applies the structural validation of [`Mic::parse`], then looks
166    /// the code up in the embedded ISO 10383 snapshot. A well-formed but
167    /// unregistered code such as `ZZZZ` is rejected here even though
168    /// [`Mic::parse`] would accept it.
169    ///
170    /// # Errors
171    ///
172    /// - The same [`ValidationError`] variants as [`Mic::parse`] for a
173    ///   structurally invalid input.
174    /// - [`ValidationError::Structure`] with
175    ///   `rule: "MIC is not in the ISO 10383 registry"` if the code is
176    ///   well-formed but absent from the embedded registry.
177    ///
178    /// # Examples
179    ///
180    /// ```
181    /// use regit_identifiers::Mic;
182    /// use regit_identifiers::errors::ValidationError;
183    ///
184    /// // XNYS is a real, registered market.
185    /// assert!(Mic::parse_registered("XNYS").is_ok());
186    ///
187    /// // ZZZZ is well-formed but identifies no market.
188    /// assert_eq!(
189    ///     Mic::parse_registered("ZZZZ"),
190    ///     Err(ValidationError::Structure {
191    ///         rule: "MIC is not in the ISO 10383 registry",
192    ///     }),
193    /// );
194    /// ```
195    #[cfg(feature = "mic-registry")]
196    pub fn parse_registered(s: &str) -> Result<Self, ValidationError> {
197        let mic = Self::parse(s)?;
198        if mic.is_registered() {
199            Ok(mic)
200        } else {
201            Err(ValidationError::Structure {
202                rule: "MIC is not in the ISO 10383 registry",
203            })
204        }
205    }
206
207    /// Wraps 4 raw bytes as a `Mic` without any validation.
208    ///
209    /// The caller asserts that `bytes` holds the 4 ASCII characters of a valid
210    /// MIC. This exists for reconstructing a `Mic` from bytes that were
211    /// validated earlier; prefer [`Mic::parse`] for any untrusted input.
212    ///
213    /// # Examples
214    ///
215    /// ```
216    /// use regit_identifiers::Mic;
217    ///
218    /// let mic = Mic::from_bytes_unchecked(*b"XNAS");
219    /// assert_eq!(mic.as_str(), "XNAS");
220    /// ```
221    #[must_use]
222    pub const fn from_bytes_unchecked(bytes: [u8; Self::LENGTH]) -> Self {
223        Self { bytes }
224    }
225
226    /// Returns the MIC as a string slice.
227    ///
228    /// # Examples
229    ///
230    /// ```
231    /// use regit_identifiers::Mic;
232    ///
233    /// assert_eq!(Mic::parse("XNAS").unwrap().as_str(), "XNAS");
234    /// ```
235    #[must_use]
236    #[inline]
237    pub fn as_str(&self) -> &str {
238        core::str::from_utf8(&self.bytes).unwrap_or("")
239    }
240
241    /// Returns the MIC as its 4 raw ASCII bytes.
242    ///
243    /// # Examples
244    ///
245    /// ```
246    /// use regit_identifiers::Mic;
247    ///
248    /// assert_eq!(Mic::parse("XNAS").unwrap().as_bytes(), b"XNAS");
249    /// ```
250    #[must_use]
251    #[inline]
252    pub fn as_bytes(&self) -> &[u8] {
253        &self.bytes
254    }
255
256    /// Returns the leading character, character 1.
257    ///
258    /// # Examples
259    ///
260    /// ```
261    /// use regit_identifiers::Mic;
262    ///
263    /// assert_eq!(Mic::parse("XNAS").unwrap().prefix(), 'X');
264    /// ```
265    #[must_use]
266    #[inline]
267    pub fn prefix(&self) -> char {
268        char::from(self.bytes[0])
269    }
270
271    /// Returns the three-character market suffix, characters 2–4.
272    ///
273    /// # Examples
274    ///
275    /// ```
276    /// use regit_identifiers::Mic;
277    ///
278    /// assert_eq!(Mic::parse("XNAS").unwrap().suffix(), "NAS");
279    /// ```
280    #[must_use]
281    #[inline]
282    pub fn suffix(&self) -> &str {
283        core::str::from_utf8(&self.bytes[1..4]).unwrap_or("")
284    }
285
286    /// Looks the MIC up in the embedded ISO 10383 registry.
287    ///
288    /// Returns the [`MicEntry`] describing the market — its operating MIC,
289    /// name, country, city, and status — or `None` if the code is not in the
290    /// snapshot. Delegates to [`crate::mic_registry::lookup`].
291    ///
292    /// # Examples
293    ///
294    /// ```
295    /// # #[cfg(feature = "mic-registry")] {
296    /// use regit_identifiers::Mic;
297    ///
298    /// let mic = Mic::parse("XNAS").unwrap();
299    /// let entry = mic.lookup().expect("XNAS is registered");
300    /// assert_eq!(entry.mic, "XNAS");
301    ///
302    /// // A well-formed but unregistered code has no entry.
303    /// assert!(Mic::parse("ZZZZ").unwrap().lookup().is_none());
304    /// # }
305    /// ```
306    #[cfg(feature = "mic-registry")]
307    #[must_use]
308    #[inline]
309    pub fn lookup(&self) -> Option<&'static MicEntry> {
310        crate::mic_registry::lookup(self.as_str())
311    }
312
313    /// Returns `true` if the MIC is present in the embedded ISO 10383
314    /// registry.
315    ///
316    /// Equivalent to `self.lookup().is_some()`.
317    ///
318    /// # Examples
319    ///
320    /// ```
321    /// # #[cfg(feature = "mic-registry")] {
322    /// use regit_identifiers::Mic;
323    ///
324    /// assert!(Mic::parse("XLON").unwrap().is_registered());
325    /// assert!(!Mic::parse("ZZZZ").unwrap().is_registered());
326    /// # }
327    /// ```
328    #[cfg(feature = "mic-registry")]
329    #[must_use]
330    #[inline]
331    pub fn is_registered(&self) -> bool {
332        self.lookup().is_some()
333    }
334}
335
336impl core::fmt::Display for Mic {
337    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
338        f.write_str(self.as_str())
339    }
340}
341
342impl core::str::FromStr for Mic {
343    type Err = ValidationError;
344
345    fn from_str(s: &str) -> Result<Self, Self::Err> {
346        Self::parse(s)
347    }
348}
349
350impl AsRef<str> for Mic {
351    fn as_ref(&self) -> &str {
352        self.as_str()
353    }
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359    use crate::test_support::display;
360    use core::str::FromStr;
361
362    /// Real, registered MICs used as regression anchors.
363    const GOLDEN: &[&str] = &[
364        "XNAS", // Nasdaq, New York
365        "XLON", // London Stock Exchange
366        "XPAR", // Euronext Paris
367        "XNYS", // New York Stock Exchange
368    ];
369
370    /// A structurally valid MIC that is not in the ISO 10383 registry.
371    const UNREGISTERED: &str = "ZZZZ";
372
373    #[test]
374    fn parses_golden_mics() {
375        for &s in GOLDEN {
376            let mic = Mic::parse(s).unwrap_or_else(|e| panic!("{s} should parse: {e}"));
377            assert_eq!(mic.as_str(), s);
378        }
379    }
380
381    #[test]
382    fn parses_unregistered_but_well_formed() {
383        let mic = Mic::parse(UNREGISTERED).unwrap();
384        assert_eq!(mic.as_str(), "ZZZZ");
385    }
386
387    #[test]
388    fn segment_accessors() {
389        let mic = Mic::parse("XNAS").unwrap();
390        assert_eq!(mic.prefix(), 'X');
391        assert_eq!(mic.suffix(), "NAS");
392        assert_eq!(mic.as_bytes(), b"XNAS");
393        assert_eq!(Mic::LENGTH, 4);
394    }
395
396    #[test]
397    fn accepts_digits_in_suffix() {
398        // Positions 2-4 admit digits; only position 1 must be a letter.
399        let mic = Mic::parse("A2XX").unwrap();
400        assert_eq!(mic.suffix(), "2XX");
401    }
402
403    #[test]
404    fn rejects_wrong_length() {
405        assert_eq!(
406            Mic::parse("XNA"),
407            Err(ValidationError::WrongLength {
408                expected: 4,
409                found: 3,
410            })
411        );
412        assert_eq!(
413            Mic::parse("XNASX"),
414            Err(ValidationError::WrongLength {
415                expected: 4,
416                found: 5,
417            })
418        );
419        assert_eq!(
420            Mic::parse(""),
421            Err(ValidationError::WrongLength {
422                expected: 4,
423                found: 0,
424            })
425        );
426    }
427
428    #[test]
429    fn rejects_digit_in_leading_position() {
430        assert_eq!(
431            Mic::parse("1NAS"),
432            Err(ValidationError::InvalidCharacter {
433                position: 1,
434                found: '1',
435            })
436        );
437    }
438
439    #[test]
440    fn rejects_lower_case() {
441        assert!(matches!(
442            Mic::parse("xnas"),
443            Err(ValidationError::InvalidCharacter { position: 1, .. })
444        ));
445        assert!(matches!(
446            Mic::parse("Xnas"),
447            Err(ValidationError::InvalidCharacter { position: 2, .. })
448        ));
449    }
450
451    #[test]
452    fn rejects_punctuation_in_suffix() {
453        assert!(matches!(
454            Mic::parse("XN-S"),
455            Err(ValidationError::InvalidCharacter { position: 3, .. })
456        ));
457    }
458
459    #[test]
460    fn rejects_non_ascii_without_panic() {
461        // A multi-byte character must be rejected cleanly.
462        assert!(Mic::parse("XNAé").is_err());
463        assert!(Mic::parse("ÉNAS").is_err());
464    }
465
466    #[test]
467    fn validate_agrees_with_parse() {
468        assert!(Mic::validate("XPAR").is_ok());
469        assert!(Mic::validate("xpar").is_err());
470    }
471
472    #[test]
473    fn round_trips_through_str() {
474        for &s in GOLDEN {
475            assert_eq!(Mic::parse(s).unwrap().as_str(), s);
476        }
477    }
478
479    #[test]
480    fn from_str_matches_parse() {
481        assert_eq!(Mic::from_str("XNAS"), Mic::parse("XNAS"));
482        assert!(Mic::from_str("nonsense").is_err());
483    }
484
485    #[test]
486    fn display_renders_identifier() {
487        let mic = Mic::parse("XNAS").unwrap();
488        assert_eq!(display(mic).as_str(), "XNAS");
489    }
490
491    #[test]
492    fn as_ref_str() {
493        let mic = Mic::parse("XNAS").unwrap();
494        let s: &str = mic.as_ref();
495        assert_eq!(s, "XNAS");
496    }
497
498    #[test]
499    fn from_bytes_unchecked_round_trip() {
500        let mic = Mic::from_bytes_unchecked(*b"XNAS");
501        assert_eq!(mic, Mic::parse("XNAS").unwrap());
502    }
503
504    #[test]
505    fn is_copy_and_eq_and_hashable() {
506        let a = Mic::parse("XNAS").unwrap();
507        let b = a; // Copy
508        assert_eq!(a, b);
509        assert_ne!(a, Mic::parse("XLON").unwrap());
510        // Usable as a map key (Eq + Hash) — checked by constructing a slice.
511        let keys = [a, b];
512        assert_eq!(keys[0], keys[1]);
513    }
514
515    #[cfg(feature = "mic-registry")]
516    #[test]
517    fn lookup_finds_golden_mics() {
518        for &s in GOLDEN {
519            let mic = Mic::parse(s).unwrap();
520            let entry = mic
521                .lookup()
522                .unwrap_or_else(|| panic!("{s} should be registered"));
523            assert_eq!(entry.mic, s);
524        }
525    }
526
527    #[cfg(feature = "mic-registry")]
528    #[test]
529    fn lookup_misses_unregistered() {
530        assert!(Mic::parse(UNREGISTERED).unwrap().lookup().is_none());
531    }
532
533    #[cfg(feature = "mic-registry")]
534    #[test]
535    fn is_registered_reflects_membership() {
536        for &s in GOLDEN {
537            assert!(Mic::parse(s).unwrap().is_registered());
538        }
539        assert!(!Mic::parse(UNREGISTERED).unwrap().is_registered());
540    }
541
542    #[cfg(feature = "mic-registry")]
543    #[test]
544    fn parse_registered_accepts_golden_mics() {
545        for &s in GOLDEN {
546            let mic = Mic::parse_registered(s)
547                .unwrap_or_else(|e| panic!("{s} should parse as registered: {e}"));
548            assert_eq!(mic.as_str(), s);
549        }
550    }
551
552    #[cfg(feature = "mic-registry")]
553    #[test]
554    fn parse_registered_rejects_unregistered() {
555        assert_eq!(
556            Mic::parse_registered(UNREGISTERED),
557            Err(ValidationError::Structure {
558                rule: "MIC is not in the ISO 10383 registry",
559            })
560        );
561    }
562
563    #[cfg(feature = "mic-registry")]
564    #[test]
565    fn parse_registered_rejects_structural_errors_first() {
566        // A structurally invalid input fails before the registry check.
567        assert_eq!(
568            Mic::parse_registered("XNA"),
569            Err(ValidationError::WrongLength {
570                expected: 4,
571                found: 3,
572            })
573        );
574        assert!(matches!(
575            Mic::parse_registered("xnas"),
576            Err(ValidationError::InvalidCharacter { position: 1, .. })
577        ));
578    }
579}