Skip to main content

regit_identifiers/
errors.rs

1// Copyright 2026 Regit.io — Nicolas Koenig
2// SPDX-License-Identifier: Apache-2.0
3
4//! Typed error enums for identifier validation and conversion.
5//!
6//! All failure paths return a typed `Result` — no `panic!()`, no `unwrap()`,
7//! no string errors. Each variant carries enough context for the caller to
8//! report precisely what was wrong and where.
9//!
10//! Two enums separate the two failure domains:
11//!
12//! - [`ValidationError`] — a string is not a well-formed identifier: wrong
13//!   length, wrong charset, a failed check digit, or a structural rule.
14//! - [`ConversionError`] — a structurally valid identifier cannot be mapped
15//!   to the requested target identifier.
16//!
17//! Both implement [`core::fmt::Display`] and [`core::error::Error`], so they
18//! compose with `?` and with `dyn Error` even under `#![no_std]`.
19//!
20//! # References
21//!
22//! - ISO 6166 (ISIN), ISO 7064 (check characters), ISO 9362 (BIC),
23//!   ISO 10383 (MIC), ISO 10962 (CFI), ISO 17442 (LEI) — the governing
24//!   standards whose rules these errors report.
25
26use core::fmt;
27
28// ─── Validation errors ───────────────────────────────────────────────────────
29
30/// Error returned when a string is not a well-formed securities identifier.
31///
32/// Every identifier type has a strict grammar: an exact length (or a small
33/// set of lengths), a per-segment character set, and — for most — a check
34/// digit. A `parse` or `validate` call returns one of these variants the
35/// moment an input violates that grammar.
36///
37/// # Examples
38///
39/// ```
40/// use regit_identifiers::errors::ValidationError;
41///
42/// let err = ValidationError::WrongLength { expected: 12, found: 11 };
43/// assert_eq!(err, ValidationError::WrongLength { expected: 12, found: 11 });
44/// ```
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum ValidationError {
47    /// The input string was empty.
48    Empty,
49    /// The input has the wrong length for this identifier.
50    WrongLength {
51        /// The length the identifier requires.
52        expected: usize,
53        /// The length that was supplied.
54        found: usize,
55    },
56    /// The character at 1-based `position` is not allowed there.
57    InvalidCharacter {
58        /// The 1-based index of the offending character.
59        position: usize,
60        /// The offending character.
61        found: char,
62    },
63    /// The recomputed check digit did not match the supplied one.
64    ///
65    /// For multi-digit schemes (LEI) this reports the first differing digit.
66    BadCheckDigit {
67        /// The check digit the algorithm computed.
68        expected: char,
69        /// The check digit that was supplied.
70        found: char,
71    },
72    /// The country-code segment is not a recognised code.
73    InvalidCountryCode,
74    /// A structural rule of the identifier was violated; `rule` names it.
75    Structure {
76        /// A short, human-readable description of the violated rule.
77        rule: &'static str,
78    },
79}
80
81impl fmt::Display for ValidationError {
82    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83        match self {
84            Self::Empty => write!(f, "input string is empty"),
85            Self::WrongLength { expected, found } => {
86                write!(f, "wrong length: expected {expected}, found {found}")
87            }
88            Self::InvalidCharacter { position, found } => {
89                write!(f, "invalid character '{found}' at position {position}")
90            }
91            Self::BadCheckDigit { expected, found } => {
92                write!(
93                    f,
94                    "check digit mismatch: expected '{expected}', found '{found}'"
95                )
96            }
97            Self::InvalidCountryCode => write!(f, "unrecognised country code"),
98            Self::Structure { rule } => write!(f, "structural rule violated: {rule}"),
99        }
100    }
101}
102
103impl core::error::Error for ValidationError {}
104
105// ─── Conversion errors ───────────────────────────────────────────────────────
106
107/// Error returned when one identifier cannot be converted into another.
108///
109/// A conversion can fail because the source identifier's country has no
110/// defined target (e.g. extracting a CUSIP from a non-US/CA ISIN), because
111/// the converted value is not itself a valid identifier, or because a
112/// [`ValidationError`] surfaced while building the target.
113///
114/// # Examples
115///
116/// ```
117/// use regit_identifiers::errors::{ConversionError, ValidationError};
118///
119/// // A validation error converts into a ConversionError with `?`.
120/// let err: ConversionError = ValidationError::Empty.into();
121/// assert!(matches!(err, ConversionError::Validation(ValidationError::Empty)));
122/// ```
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub enum ConversionError {
125    /// The source identifier's country has no defined target for this
126    /// conversion (e.g. extracting a CUSIP from a non-US/CA ISIN).
127    UnsupportedCountry,
128    /// The conversion produced a value that is not itself valid; `reason`
129    /// names the problem.
130    NotConvertible {
131        /// A short, human-readable description of why the value is invalid.
132        reason: &'static str,
133    },
134    /// A validation error surfaced while building the converted identifier.
135    Validation(ValidationError),
136}
137
138impl fmt::Display for ConversionError {
139    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
140        match self {
141            Self::UnsupportedCountry => {
142                write!(
143                    f,
144                    "source country has no defined target for this conversion"
145                )
146            }
147            Self::NotConvertible { reason } => {
148                write!(f, "value is not convertible: {reason}")
149            }
150            Self::Validation(e) => write!(f, "converted identifier is invalid: {e}"),
151        }
152    }
153}
154
155impl core::error::Error for ConversionError {
156    fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
157        match self {
158            Self::Validation(e) => Some(e),
159            _ => None,
160        }
161    }
162}
163
164impl From<ValidationError> for ConversionError {
165    fn from(e: ValidationError) -> Self {
166        Self::Validation(e)
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use crate::test_support::{debug, display};
174
175    #[test]
176    fn validation_error_display_empty() {
177        assert_eq!(
178            display(ValidationError::Empty).as_str(),
179            "input string is empty"
180        );
181    }
182
183    #[test]
184    fn validation_error_display_wrong_length() {
185        let err = ValidationError::WrongLength {
186            expected: 12,
187            found: 11,
188        };
189        assert_eq!(display(err).as_str(), "wrong length: expected 12, found 11");
190    }
191
192    #[test]
193    fn validation_error_display_invalid_character() {
194        let err = ValidationError::InvalidCharacter {
195            position: 3,
196            found: '/',
197        };
198        assert_eq!(display(err).as_str(), "invalid character '/' at position 3");
199    }
200
201    #[test]
202    fn validation_error_display_bad_check_digit() {
203        let err = ValidationError::BadCheckDigit {
204            expected: '5',
205            found: '4',
206        };
207        assert_eq!(
208            display(err).as_str(),
209            "check digit mismatch: expected '5', found '4'"
210        );
211    }
212
213    #[test]
214    fn validation_error_display_country_and_structure() {
215        assert_eq!(
216            display(ValidationError::InvalidCountryCode).as_str(),
217            "unrecognised country code"
218        );
219        assert_eq!(
220            display(ValidationError::Structure {
221                rule: "BIC length must be 8 or 11",
222            })
223            .as_str(),
224            "structural rule violated: BIC length must be 8 or 11"
225        );
226    }
227
228    #[test]
229    fn validation_error_display_has_no_trailing_period() {
230        for err in [
231            ValidationError::Empty,
232            ValidationError::WrongLength {
233                expected: 1,
234                found: 2,
235            },
236            ValidationError::InvalidCharacter {
237                position: 1,
238                found: 'x',
239            },
240            ValidationError::BadCheckDigit {
241                expected: '0',
242                found: '1',
243            },
244            ValidationError::InvalidCountryCode,
245            ValidationError::Structure { rule: "r" },
246        ] {
247            assert!(!display(err).as_str().ends_with('.'));
248        }
249    }
250
251    #[test]
252    fn validation_error_is_error_trait() {
253        let err: &dyn core::error::Error = &ValidationError::Empty;
254        assert!(err.source().is_none());
255    }
256
257    #[test]
258    fn validation_error_copy_eq() {
259        let err = ValidationError::InvalidCountryCode;
260        let copy = err;
261        assert_eq!(err, copy);
262    }
263
264    #[test]
265    fn conversion_error_display() {
266        assert_eq!(
267            display(ConversionError::UnsupportedCountry).as_str(),
268            "source country has no defined target for this conversion"
269        );
270        assert!(
271            display(ConversionError::NotConvertible {
272                reason: "leading 00 missing",
273            })
274            .as_str()
275            .contains("leading 00 missing")
276        );
277        assert!(
278            display(ConversionError::Validation(ValidationError::Empty))
279                .as_str()
280                .contains("empty")
281        );
282    }
283
284    #[test]
285    fn conversion_error_from_validation_and_source() {
286        let ve = ValidationError::WrongLength {
287            expected: 9,
288            found: 8,
289        };
290        let ce: ConversionError = ve.into();
291        assert!(matches!(ce, ConversionError::Validation(_)));
292        let dyn_err: &dyn core::error::Error = &ce;
293        assert!(dyn_err.source().is_some());
294
295        let no_src: &dyn core::error::Error = &ConversionError::UnsupportedCountry;
296        assert!(no_src.source().is_none());
297    }
298
299    #[test]
300    fn errors_debug() {
301        assert!(debug(ValidationError::Empty).as_str().contains("Empty"));
302        assert!(
303            debug(ConversionError::UnsupportedCountry)
304                .as_str()
305                .contains("UnsupportedCountry")
306        );
307    }
308}