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}