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}