Skip to main content

regit_identifiers/
sedol.rs

1// Copyright 2026 Regit.io — Nicolas Koenig
2// SPDX-License-Identifier: Apache-2.0
3
4//! SEDOL — Stock Exchange Daily Official List number (London Stock Exchange).
5//!
6//! A SEDOL is the national securities identifier for instruments listed in
7//! the United Kingdom and Ireland. It is exactly 7 characters in two
8//! segments:
9//!
10//! ```text
11//!   0 2 6 3 4 9 4
12//!   └────┬────┘ │
13//!        │       └ check digit  [6]      one digit [0-9]
14//!        └──────── body          [0..6]   six characters [0-9 + consonants]
15//! ```
16//!
17//! - The **body** is six characters drawn from the digits and the consonants
18//!   — a vowel (`A`, `E`, `I`, `O`, `U`) is never used, so a check character
19//!   can never be confused for part of a word. Legacy SEDOLs, issued before
20//!   the 2004 switch to an alphanumeric scheme, are purely numeric.
21//! - The **check digit** is the weighted-sum modulus of the six-character
22//!   body — see [`crate::checkdigit::sedol_check_digit`].
23//!
24//! [`Sedol::parse`] enforces every rule: exact length, the body character
25//! set with vowels rejected, a digit in the check position, and a check
26//! digit that is recomputed and verified — never trusted.
27//!
28//! # References
29//!
30//! - London Stock Exchange — SEDOL Masterfile service description.
31
32use crate::checkdigit;
33use crate::errors::ValidationError;
34
35/// A validated Stock Exchange Daily Official List number (SEDOL).
36///
37/// A `Sedol` can only be created by [`Sedol::parse`] (or the explicitly
38/// unchecked [`Sedol::from_bytes_unchecked`]), so a value of this type is a
39/// proof that the 7 characters form a structurally valid SEDOL with a
40/// correct check digit. It stores the identifier inline as `[u8; 7]`, is
41/// `Copy`, and allocates nothing.
42///
43/// # Examples
44///
45/// ```
46/// use regit_identifiers::Sedol;
47///
48/// let sedol = Sedol::parse("0263494").unwrap();
49/// assert_eq!(sedol.body(), "026349");
50/// assert_eq!(sedol.check_digit(), '4');
51/// assert_eq!(sedol.as_str(), "0263494");
52/// ```
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
54pub struct Sedol {
55    /// The 7 validated ASCII bytes of the identifier.
56    bytes: [u8; Self::LENGTH],
57}
58
59impl Sedol {
60    /// The number of characters in a SEDOL.
61    pub const LENGTH: usize = 7;
62
63    /// Parses and fully validates a SEDOL.
64    ///
65    /// Validation is strict and, in order: the input must be exactly 7
66    /// characters; characters 1–6 must each be an ASCII digit or an
67    /// upper-case consonant (a vowel is rejected); character 7 must be an
68    /// ASCII digit; and the check digit must equal the value recomputed from
69    /// the six-character body.
70    ///
71    /// # Errors
72    ///
73    /// - [`ValidationError::WrongLength`] if the input is not 7 characters.
74    /// - [`ValidationError::InvalidCharacter`] if a character falls outside
75    ///   the set its position allows — this rejects a vowel in the body, a
76    ///   non-digit check character, lower-case input, and any non-ASCII
77    ///   character.
78    /// - [`ValidationError::BadCheckDigit`] if the supplied check digit does
79    ///   not match the recomputed one.
80    ///
81    /// # Examples
82    ///
83    /// ```
84    /// use regit_identifiers::Sedol;
85    /// use regit_identifiers::errors::ValidationError;
86    ///
87    /// assert!(Sedol::parse("0263494").is_ok());
88    ///
89    /// // A single wrong digit is caught, not silently accepted.
90    /// assert_eq!(
91    ///     Sedol::parse("0263495"),
92    ///     Err(ValidationError::BadCheckDigit { expected: '4', found: '5' }),
93    /// );
94    /// ```
95    pub fn parse(s: &str) -> Result<Self, ValidationError> {
96        // A SEDOL is exactly 7 characters.
97        let found = s.chars().count();
98        if found != Self::LENGTH {
99            return Err(ValidationError::WrongLength {
100                expected: Self::LENGTH,
101                found,
102            });
103        }
104        // Per-position character set: [0..6] are digits or non-vowel A-Z,
105        // [6] is a digit. A non-ASCII character fails every predicate and is
106        // rejected here.
107        for (i, ch) in s.chars().enumerate() {
108            let legal = if i == Self::LENGTH - 1 {
109                ch.is_ascii_digit()
110            } else {
111                ch.is_ascii_digit()
112                    || (ch.is_ascii_uppercase() && !crate::charset::is_vowel(ch as u8))
113            };
114            if !legal {
115                return Err(ValidationError::InvalidCharacter {
116                    position: i + 1,
117                    found: ch,
118                });
119            }
120        }
121        // Every character is ASCII, so the string is exactly 7 ASCII bytes.
122        let mut bytes = [0u8; Self::LENGTH];
123        bytes.copy_from_slice(s.as_bytes());
124
125        // Recompute the check digit from the six-character body and compare.
126        let body = core::str::from_utf8(&bytes[0..6]).unwrap_or("");
127        let expected = checkdigit::sedol_check_digit(body)?;
128        let supplied = char::from(bytes[6]);
129        if expected != supplied {
130            return Err(ValidationError::BadCheckDigit {
131                expected,
132                found: supplied,
133            });
134        }
135        Ok(Self { bytes })
136    }
137
138    /// Validates a SEDOL without constructing one.
139    ///
140    /// Equivalent to `Sedol::parse(s).map(|_| ())`; use it when only the
141    /// verdict is needed.
142    ///
143    /// # Errors
144    ///
145    /// Returns the same [`ValidationError`] variants as [`Sedol::parse`].
146    ///
147    /// # Examples
148    ///
149    /// ```
150    /// use regit_identifiers::Sedol;
151    ///
152    /// assert!(Sedol::validate("0263494").is_ok());
153    /// assert!(Sedol::validate("0263495").is_err());
154    /// ```
155    pub fn validate(s: &str) -> Result<(), ValidationError> {
156        Self::parse(s).map(|_| ())
157    }
158
159    /// Wraps 7 raw bytes as a `Sedol` without any validation.
160    ///
161    /// The caller asserts that `bytes` holds the 7 ASCII characters of a
162    /// valid SEDOL. This exists for reconstructing a `Sedol` from bytes that
163    /// were validated earlier; prefer [`Sedol::parse`] for any untrusted
164    /// input.
165    ///
166    /// # Examples
167    ///
168    /// ```
169    /// use regit_identifiers::Sedol;
170    ///
171    /// let sedol = Sedol::from_bytes_unchecked(*b"0263494");
172    /// assert_eq!(sedol.as_str(), "0263494");
173    /// ```
174    #[must_use]
175    pub const fn from_bytes_unchecked(bytes: [u8; Self::LENGTH]) -> Self {
176        Self { bytes }
177    }
178
179    /// Returns the SEDOL as a string slice.
180    ///
181    /// # Examples
182    ///
183    /// ```
184    /// use regit_identifiers::Sedol;
185    ///
186    /// assert_eq!(Sedol::parse("0263494").unwrap().as_str(), "0263494");
187    /// ```
188    #[must_use]
189    #[inline]
190    pub fn as_str(&self) -> &str {
191        core::str::from_utf8(&self.bytes).unwrap_or("")
192    }
193
194    /// Returns the SEDOL as its 7 raw ASCII bytes.
195    ///
196    /// # Examples
197    ///
198    /// ```
199    /// use regit_identifiers::Sedol;
200    ///
201    /// assert_eq!(Sedol::parse("0263494").unwrap().as_bytes(), b"0263494");
202    /// ```
203    #[must_use]
204    #[inline]
205    pub fn as_bytes(&self) -> &[u8] {
206        &self.bytes
207    }
208
209    /// Returns the six-character body, characters 1–6.
210    ///
211    /// # Examples
212    ///
213    /// ```
214    /// use regit_identifiers::Sedol;
215    ///
216    /// assert_eq!(Sedol::parse("0263494").unwrap().body(), "026349");
217    /// ```
218    #[must_use]
219    #[inline]
220    pub fn body(&self) -> &str {
221        core::str::from_utf8(&self.bytes[0..6]).unwrap_or("")
222    }
223
224    /// Returns the check digit, character 7.
225    ///
226    /// # Examples
227    ///
228    /// ```
229    /// use regit_identifiers::Sedol;
230    ///
231    /// assert_eq!(Sedol::parse("0263494").unwrap().check_digit(), '4');
232    /// ```
233    #[must_use]
234    #[inline]
235    pub fn check_digit(&self) -> char {
236        char::from(self.bytes[6])
237    }
238
239    /// Returns `true` if this is a legacy purely-numeric SEDOL.
240    ///
241    /// SEDOLs issued before the 2004 switch to an alphanumeric scheme have a
242    /// body consisting solely of digits; this is `true` exactly when all six
243    /// body characters are ASCII digits.
244    ///
245    /// # Examples
246    ///
247    /// ```
248    /// use regit_identifiers::Sedol;
249    ///
250    /// assert!(Sedol::parse("0263494").unwrap().is_legacy_numeric());
251    /// assert!(!Sedol::parse("B0WNLY7").unwrap().is_legacy_numeric());
252    /// ```
253    #[must_use]
254    #[inline]
255    pub fn is_legacy_numeric(&self) -> bool {
256        self.bytes[0..6].iter().all(u8::is_ascii_digit)
257    }
258}
259
260impl core::fmt::Display for Sedol {
261    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
262        f.write_str(self.as_str())
263    }
264}
265
266impl core::str::FromStr for Sedol {
267    type Err = ValidationError;
268
269    fn from_str(s: &str) -> Result<Self, Self::Err> {
270        Self::parse(s)
271    }
272}
273
274impl AsRef<str> for Sedol {
275    fn as_ref(&self) -> &str {
276        self.as_str()
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283    use crate::test_support::display;
284    use core::str::FromStr;
285
286    /// Real, well-known SEDOLs used as regression anchors.
287    const GOLDEN: &[&str] = &[
288        "0263494", // BAE Systems plc
289        "0540528", // a second legacy numeric SEDOL
290        "B0WNLY7", // a post-2004 alphanumeric SEDOL
291    ];
292
293    #[test]
294    fn parses_golden_sedols() {
295        for &s in GOLDEN {
296            let sedol = Sedol::parse(s).unwrap_or_else(|e| panic!("{s} should parse: {e}"));
297            assert_eq!(sedol.as_str(), s);
298        }
299    }
300
301    #[test]
302    fn segment_accessors() {
303        let sedol = Sedol::parse("0263494").unwrap();
304        assert_eq!(sedol.body(), "026349");
305        assert_eq!(sedol.check_digit(), '4');
306        assert_eq!(sedol.as_bytes(), b"0263494");
307        assert_eq!(Sedol::LENGTH, 7);
308    }
309
310    #[test]
311    fn accepts_alphanumeric_body() {
312        // A post-2004 SEDOL with consonants in the body.
313        let sedol = Sedol::parse("B0WNLY7").unwrap();
314        assert_eq!(sedol.body(), "B0WNLY");
315        assert_eq!(sedol.check_digit(), '7');
316    }
317
318    #[test]
319    fn is_legacy_numeric_classifies() {
320        assert!(Sedol::parse("0263494").unwrap().is_legacy_numeric());
321        assert!(Sedol::parse("0540528").unwrap().is_legacy_numeric());
322        assert!(!Sedol::parse("B0WNLY7").unwrap().is_legacy_numeric());
323    }
324
325    #[test]
326    fn rejects_bad_check_digit() {
327        assert_eq!(
328            Sedol::parse("0263495"),
329            Err(ValidationError::BadCheckDigit {
330                expected: '4',
331                found: '5',
332            })
333        );
334    }
335
336    #[test]
337    fn rejects_wrong_length() {
338        assert_eq!(
339            Sedol::parse("026349"),
340            Err(ValidationError::WrongLength {
341                expected: 7,
342                found: 6,
343            })
344        );
345        assert_eq!(
346            Sedol::parse(""),
347            Err(ValidationError::WrongLength {
348                expected: 7,
349                found: 0,
350            })
351        );
352    }
353
354    #[test]
355    fn rejects_vowel_in_body() {
356        // A vowel can never appear in a SEDOL body.
357        assert_eq!(
358            Sedol::parse("B0WNLA7"),
359            Err(ValidationError::InvalidCharacter {
360                position: 6,
361                found: 'A',
362            })
363        );
364    }
365
366    #[test]
367    fn rejects_lower_case() {
368        assert!(matches!(
369            Sedol::parse("b0wnly7"),
370            Err(ValidationError::InvalidCharacter { position: 1, .. })
371        ));
372    }
373
374    #[test]
375    fn rejects_non_digit_check_position() {
376        // Character 7 must be a digit.
377        assert!(matches!(
378            Sedol::parse("026349B"),
379            Err(ValidationError::InvalidCharacter { position: 7, .. })
380        ));
381    }
382
383    #[test]
384    fn rejects_non_ascii_without_panic() {
385        // A multi-byte character must be rejected cleanly.
386        assert!(Sedol::parse("026349é").is_err());
387        assert!(Sedol::parse("é263494").is_err());
388    }
389
390    #[test]
391    fn round_trips_through_str() {
392        for &s in GOLDEN {
393            assert_eq!(Sedol::parse(s).unwrap().as_str(), s);
394        }
395    }
396
397    #[test]
398    fn from_str_matches_parse() {
399        assert_eq!(Sedol::from_str("0263494"), Sedol::parse("0263494"));
400        assert!(Sedol::from_str("nonsense").is_err());
401    }
402
403    #[test]
404    fn display_renders_identifier() {
405        let sedol = Sedol::parse("0263494").unwrap();
406        assert_eq!(display(sedol).as_str(), "0263494");
407    }
408
409    #[test]
410    fn as_ref_str() {
411        let sedol = Sedol::parse("0263494").unwrap();
412        let s: &str = sedol.as_ref();
413        assert_eq!(s, "0263494");
414    }
415
416    #[test]
417    fn from_bytes_unchecked_round_trip() {
418        let sedol = Sedol::from_bytes_unchecked(*b"0263494");
419        assert_eq!(sedol, Sedol::parse("0263494").unwrap());
420    }
421
422    #[test]
423    fn is_copy_and_eq_and_hashable() {
424        let a = Sedol::parse("0263494").unwrap();
425        let b = a; // Copy
426        assert_eq!(a, b);
427        assert_ne!(a, Sedol::parse("B0WNLY7").unwrap());
428        // Usable as a map key (Eq + Hash) — checked by constructing a slice.
429        let keys = [a, b];
430        assert_eq!(keys[0], keys[1]);
431    }
432}