Skip to main content

regit_identifiers/
convert.rs

1// Copyright 2026 Regit.io — Nicolas Koenig
2// SPDX-License-Identifier: Apache-2.0
3
4//! Conversions between securities identifiers.
5//!
6//! An ISIN is the international wrapper around a *national* securities number
7//! (the NSIN). For three jurisdictions that wrapper is exact and reversible:
8//!
9//! ```text
10//!   US / CA ISIN   country prefix · 9-char NSIN · check   NSIN  IS  the CUSIP
11//!   GB / IE ISIN   country prefix · 00 + SEDOL  · check   NSIN  IS  00 + SEDOL
12//!   CH / LI ISIN   country prefix · 0…0 + VALOR · check   NSIN  IS  padded VALOR
13//! ```
14//!
15//! - A **US** or **CA** ISIN embeds a [`Cusip`] verbatim as its nine-character
16//!   NSIN. Extracting the CUSIP is taking those nine characters; building the
17//!   ISIN is prefixing `US` or `CA` and computing a fresh ISIN check digit.
18//! - A **GB** or **IE** ISIN embeds a seven-character [`Sedol`] right-aligned
19//!   in the nine-character NSIN, left-padded with the two literal characters
20//!   `00`. Extracting the SEDOL strips that `00`.
21//! - A **CH** or **LI** ISIN embeds a [`Valor`] — a one-to-nine-digit number —
22//!   left-padded with zeros to nine digits. Extracting the VALOR strips the
23//!   leading zeros.
24//!
25//! The two check-digit schemes are **independent**: the CUSIP, SEDOL, and ISIN
26//! algorithms are unrelated, so every conversion recomputes — never reuses —
27//! the target's check digit and re-parses the result through the target type's
28//! own validator. A conversion with no defined meaning (a non-US/CA ISIN to a
29//! CUSIP, say) returns [`ConversionError::UnsupportedCountry`]; a conversion
30//! whose result is not a valid identifier returns
31//! [`ConversionError::NotConvertible`] or [`ConversionError::Validation`]. A
32//! conversion never returns a wrong answer.
33//!
34//! Every `*_to_isin` function and [`build_isin`] left-pads a short national
35//! number into the nine-character NSIN field, so a [`Sedol`] (`00` + 7) and a
36//! [`Valor`] (zero-padded to 9) land where the standard places them.
37//!
38//! # References
39//!
40//! - ISO 6166 (ISIN), ANSI X9.6 (CUSIP), London Stock Exchange (SEDOL),
41//!   SIX Financial Information (VALOR) — the schemes whose embedding rules
42//!   these conversions implement.
43
44use crate::checkdigit;
45use crate::country;
46use crate::cusip::Cusip;
47use crate::errors::ConversionError;
48use crate::isin::Isin;
49use crate::sedol::Sedol;
50use crate::valor::Valor;
51
52/// Length of an ISIN's nine-character NSIN field.
53const NSIN_LEN: usize = 9;
54
55/// Length of an ISIN's eleven-character body (country prefix + NSIN).
56const BODY_LEN: usize = 11;
57
58/// Assembles an ISIN from a two-character country prefix and a national
59/// number, computing the ISIN check digit.
60///
61/// The national number is left-zero-padded into the nine-character NSIN
62/// field, the eleven-character body is formed, the ISIN check digit is
63/// computed from that body via [`checkdigit::isin_check_digit`], and the
64/// resulting twelve characters are re-parsed through [`Isin::parse`] so the
65/// returned value is always a fully validated ISIN.
66///
67/// # Errors
68///
69/// - [`ConversionError::UnsupportedCountry`] if `country` is not a recognised
70///   ISIN prefix (an ISO 3166-1 code or an ISIN substitute prefix).
71/// - [`ConversionError::NotConvertible`] if `country` is not exactly two
72///   characters, or if `nsin` is empty or longer than nine characters.
73/// - [`ConversionError::Validation`] if the assembled string is not a valid
74///   ISIN — for instance because `nsin` contains a character outside
75///   `[A-Z0-9]`.
76///
77/// # Examples
78///
79/// ```
80/// use regit_identifiers::convert::build_isin;
81///
82/// // Apple's CUSIP, wrapped into its ISIN with a fresh check digit.
83/// let isin = build_isin("US", "037833100").unwrap();
84/// assert_eq!(isin.as_str(), "US0378331005");
85/// ```
86pub fn build_isin(country: &str, nsin: &str) -> Result<Isin, ConversionError> {
87    // The country prefix must be exactly two ASCII characters and a
88    // recognised ISIN prefix.
89    if country.len() != 2 || !country.is_ascii() {
90        return Err(ConversionError::NotConvertible {
91            reason: "country prefix must be exactly two characters",
92        });
93    }
94    if !country::is_isin_prefix(country) {
95        return Err(ConversionError::UnsupportedCountry);
96    }
97    // The national number must be ASCII and fit, left-padded, into the
98    // nine-character NSIN field.
99    if !nsin.is_ascii() {
100        return Err(ConversionError::NotConvertible {
101            reason: "national number must be ASCII",
102        });
103    }
104    let nsin_bytes = nsin.as_bytes();
105    if nsin_bytes.is_empty() || nsin_bytes.len() > NSIN_LEN {
106        return Err(ConversionError::NotConvertible {
107            reason: "national number must be 1 to 9 characters",
108        });
109    }
110    // Build the eleven-character body: country prefix, then the national
111    // number right-aligned in nine characters with leading '0' padding.
112    let mut body = [b'0'; BODY_LEN];
113    body[0] = country.as_bytes()[0];
114    body[1] = country.as_bytes()[1];
115    let pad = NSIN_LEN - nsin_bytes.len();
116    if let Some(slot) = body.get_mut(2 + pad..BODY_LEN) {
117        slot.copy_from_slice(nsin_bytes);
118    }
119    let body_str = core::str::from_utf8(&body).unwrap_or("");
120    // Compute the ISIN check digit from the assembled body.
121    let check = checkdigit::isin_check_digit(body_str)?;
122    // Form the twelve-character ISIN and re-parse it for full validation.
123    let mut full = [0u8; Isin::LENGTH];
124    if let Some(slot) = full.get_mut(0..BODY_LEN) {
125        slot.copy_from_slice(&body);
126    }
127    full[BODY_LEN] = check as u8;
128    let full_str = core::str::from_utf8(&full).unwrap_or("");
129    Ok(Isin::parse(full_str)?)
130}
131
132/// Extracts the [`Cusip`] embedded in a United States or Canada ISIN.
133///
134/// For a `US` or `CA` ISIN the nine-character NSIN *is* the CUSIP. The nine
135/// characters are re-parsed through [`Cusip::parse`], so the CUSIP's own check
136/// digit — computed by a different algorithm than the ISIN's — is verified.
137///
138/// # Errors
139///
140/// - [`ConversionError::UnsupportedCountry`] if the ISIN's country prefix is
141///   not `US` or `CA`; only those jurisdictions use a CUSIP as their NSIN.
142/// - [`ConversionError::Validation`] if the nine-character NSIN is not itself
143///   a valid CUSIP.
144///
145/// # Examples
146///
147/// ```
148/// use regit_identifiers::Isin;
149/// use regit_identifiers::convert::isin_to_cusip;
150///
151/// let isin = Isin::parse("US0378331005").unwrap();
152/// assert_eq!(isin_to_cusip(&isin).unwrap().as_str(), "037833100");
153/// ```
154pub fn isin_to_cusip(isin: &Isin) -> Result<Cusip, ConversionError> {
155    match isin.country_code() {
156        "US" | "CA" => Ok(Cusip::parse(isin.nsin())?),
157        _ => Err(ConversionError::UnsupportedCountry),
158    }
159}
160
161/// Wraps a [`Cusip`] into an ISIN for the given country.
162///
163/// The CUSIP becomes the ISIN's nine-character NSIN verbatim; `country`
164/// supplies the prefix and a fresh ISIN check digit is computed. Conventional
165/// callers pass `"US"` or `"CA"`, but any recognised ISIN prefix is accepted —
166/// the structural conversion is well-defined for all of them.
167///
168/// # Errors
169///
170/// - [`ConversionError::UnsupportedCountry`] if `country` is not a recognised
171///   ISIN prefix.
172/// - [`ConversionError::NotConvertible`] if `country` is not exactly two
173///   characters.
174/// - [`ConversionError::Validation`] if the assembled string is not a valid
175///   ISIN.
176///
177/// # Examples
178///
179/// ```
180/// use regit_identifiers::Cusip;
181/// use regit_identifiers::convert::cusip_to_isin;
182///
183/// let cusip = Cusip::parse("037833100").unwrap();
184/// assert_eq!(cusip_to_isin(&cusip, "US").unwrap().as_str(), "US0378331005");
185/// ```
186pub fn cusip_to_isin(cusip: &Cusip, country: &str) -> Result<Isin, ConversionError> {
187    build_isin(country, cusip.as_str())
188}
189
190/// Extracts the [`Sedol`] embedded in a United Kingdom or Ireland ISIN.
191///
192/// For a `GB` or `IE` ISIN the seven-character SEDOL sits right-aligned in the
193/// nine-character NSIN, left-padded with the two literal characters `00`. The
194/// leading `00` is stripped and the remaining seven characters are re-parsed
195/// through [`Sedol::parse`], verifying the SEDOL's own check digit.
196///
197/// # Errors
198///
199/// - [`ConversionError::UnsupportedCountry`] if the ISIN's country prefix is
200///   not `GB` or `IE`; only those jurisdictions embed a SEDOL.
201/// - [`ConversionError::NotConvertible`] if the NSIN does not begin with the
202///   literal `00` padding a SEDOL requires.
203/// - [`ConversionError::Validation`] if the seven characters that remain are
204///   not themselves a valid SEDOL.
205///
206/// # Examples
207///
208/// ```
209/// use regit_identifiers::Isin;
210/// use regit_identifiers::convert::isin_to_sedol;
211///
212/// let isin = Isin::parse("GB0002634946").unwrap();
213/// assert_eq!(isin_to_sedol(&isin).unwrap().as_str(), "0263494");
214/// ```
215pub fn isin_to_sedol(isin: &Isin) -> Result<Sedol, ConversionError> {
216    match isin.country_code() {
217        "GB" | "IE" => {}
218        _ => return Err(ConversionError::UnsupportedCountry),
219    }
220    let nsin = isin.nsin();
221    // The SEDOL occupies the trailing seven characters; the first two must be
222    // the literal "00" padding.
223    let rest = nsin
224        .strip_prefix("00")
225        .ok_or(ConversionError::NotConvertible {
226            reason: "GB/IE NSIN must begin with 00 padding a SEDOL",
227        })?;
228    Ok(Sedol::parse(rest)?)
229}
230
231/// Wraps a [`Sedol`] into an ISIN for the given country.
232///
233/// The seven-character SEDOL is left-padded with the two literal characters
234/// `00` to form the nine-character NSIN; `country` supplies the prefix and a
235/// fresh ISIN check digit is computed. Conventional callers pass `"GB"` or
236/// `"IE"`.
237///
238/// # Errors
239///
240/// - [`ConversionError::UnsupportedCountry`] if `country` is not a recognised
241///   ISIN prefix.
242/// - [`ConversionError::NotConvertible`] if `country` is not exactly two
243///   characters.
244/// - [`ConversionError::Validation`] if the assembled string is not a valid
245///   ISIN.
246///
247/// # Examples
248///
249/// ```
250/// use regit_identifiers::Sedol;
251/// use regit_identifiers::convert::sedol_to_isin;
252///
253/// let sedol = Sedol::parse("0263494").unwrap();
254/// assert_eq!(sedol_to_isin(&sedol, "GB").unwrap().as_str(), "GB0002634946");
255/// ```
256pub fn sedol_to_isin(sedol: &Sedol, country: &str) -> Result<Isin, ConversionError> {
257    // The NSIN is "00" followed by the seven-character SEDOL.
258    let mut nsin = [b'0'; NSIN_LEN];
259    if let Some(slot) = nsin.get_mut(2..NSIN_LEN) {
260        slot.copy_from_slice(sedol.as_bytes());
261    }
262    let nsin_str = core::str::from_utf8(&nsin).unwrap_or("");
263    build_isin(country, nsin_str)
264}
265
266/// Extracts the [`Valor`] embedded in a Switzerland or Liechtenstein ISIN.
267///
268/// For a `CH` or `LI` ISIN the VALOR is left-padded with zeros to fill the
269/// nine-character NSIN. The leading zeros are stripped — at least one digit is
270/// always kept, so a NSIN of all zeros yields the VALOR `0` — and the result
271/// is re-parsed through [`Valor::parse`].
272///
273/// # Errors
274///
275/// - [`ConversionError::UnsupportedCountry`] if the ISIN's country prefix is
276///   not `CH` or `LI`; only those jurisdictions embed a VALOR.
277/// - [`ConversionError::Validation`] if the stripped digit string is not
278///   itself a valid VALOR — for instance because the NSIN contained a letter.
279///
280/// # Examples
281///
282/// ```
283/// use regit_identifiers::Isin;
284/// use regit_identifiers::convert::isin_to_valor;
285///
286/// let isin = Isin::parse("CH0012138530").unwrap();
287/// assert_eq!(isin_to_valor(&isin).unwrap().as_str(), "1213853");
288/// ```
289pub fn isin_to_valor(isin: &Isin) -> Result<Valor, ConversionError> {
290    match isin.country_code() {
291        "CH" | "LI" => {}
292        _ => return Err(ConversionError::UnsupportedCountry),
293    }
294    let nsin = isin.nsin();
295    // Strip leading zeros, keeping at least the final character so an
296    // all-zero NSIN yields the VALOR "0" rather than an empty string.
297    let trimmed = nsin.trim_start_matches('0');
298    let valor = if trimmed.is_empty() {
299        // The NSIN is all zeros; the VALOR is a single zero digit.
300        "0"
301    } else {
302        trimmed
303    };
304    Ok(Valor::parse(valor)?)
305}
306
307/// Wraps a [`Valor`] into an ISIN for the given country.
308///
309/// The VALOR's one-to-nine digits are left-zero-padded to the nine-character
310/// NSIN; `country` supplies the prefix and a fresh ISIN check digit is
311/// computed. Conventional callers pass `"CH"` or `"LI"`.
312///
313/// # Errors
314///
315/// - [`ConversionError::UnsupportedCountry`] if `country` is not a recognised
316///   ISIN prefix.
317/// - [`ConversionError::NotConvertible`] if `country` is not exactly two
318///   characters.
319/// - [`ConversionError::Validation`] if the assembled string is not a valid
320///   ISIN.
321///
322/// # Examples
323///
324/// ```
325/// use regit_identifiers::Valor;
326/// use regit_identifiers::convert::valor_to_isin;
327///
328/// let valor = Valor::parse("1213853").unwrap();
329/// assert_eq!(valor_to_isin(&valor, "CH").unwrap().as_str(), "CH0012138530");
330/// ```
331pub fn valor_to_isin(valor: &Valor, country: &str) -> Result<Isin, ConversionError> {
332    // `build_isin` left-zero-pads the VALOR's digits into the NSIN field.
333    build_isin(country, valor.as_str())
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339    use crate::errors::ValidationError;
340
341    // ─── build_isin ──────────────────────────────────────────────────────
342
343    #[test]
344    fn build_isin_apple() {
345        // The CUSIP wrapped with a fresh ISIN check digit reproduces the
346        // real Apple ISIN exactly.
347        let isin = build_isin("US", "037833100").unwrap();
348        assert_eq!(isin.as_str(), "US0378331005");
349    }
350
351    #[test]
352    fn build_isin_agrees_with_isin_parse() {
353        // `build_isin` must produce exactly what `Isin::parse` accepts.
354        for &(country, nsin, expected) in &[
355            ("US", "037833100", "US0378331005"),
356            ("US", "594918104", "US5949181045"),
357            ("GB", "000263494", "GB0002634946"),
358            ("DE", "000BAY001", "DE000BAY0017"),
359            ("CH", "001213853", "CH0012138530"),
360        ] {
361            let built = build_isin(country, nsin).unwrap();
362            assert_eq!(built.as_str(), expected);
363            assert_eq!(built, Isin::parse(expected).unwrap());
364        }
365    }
366
367    #[test]
368    fn build_isin_left_pads_short_nsin() {
369        // A national number shorter than nine characters is left-zero-padded.
370        let isin = build_isin("CH", "1213853").unwrap();
371        assert_eq!(isin.as_str(), "CH0012138530");
372        assert_eq!(isin.nsin(), "001213853");
373    }
374
375    #[test]
376    fn build_isin_accepts_single_character_nsin() {
377        // A one-character national number pads to nine zeros-then-digit.
378        let isin = build_isin("US", "1").unwrap();
379        assert_eq!(isin.nsin(), "000000001");
380    }
381
382    #[test]
383    fn build_isin_rejects_unknown_country() {
384        assert_eq!(
385            build_isin("ZZ", "037833100"),
386            Err(ConversionError::UnsupportedCountry)
387        );
388    }
389
390    #[test]
391    fn build_isin_rejects_wrong_country_length() {
392        assert!(matches!(
393            build_isin("USA", "037833100"),
394            Err(ConversionError::NotConvertible { .. })
395        ));
396        assert!(matches!(
397            build_isin("U", "037833100"),
398            Err(ConversionError::NotConvertible { .. })
399        ));
400    }
401
402    #[test]
403    fn build_isin_rejects_empty_and_overlong_nsin() {
404        assert!(matches!(
405            build_isin("US", ""),
406            Err(ConversionError::NotConvertible { .. })
407        ));
408        assert!(matches!(
409            build_isin("US", "0123456789"),
410            Err(ConversionError::NotConvertible { .. })
411        ));
412    }
413
414    #[test]
415    fn build_isin_rejects_bad_nsin_character() {
416        // A lower-case or otherwise illegal NSIN character surfaces as a
417        // validation error from the check-digit step.
418        assert!(matches!(
419            build_isin("US", "03783310a"),
420            Err(ConversionError::Validation(_))
421        ));
422    }
423
424    #[test]
425    fn build_isin_rejects_non_ascii() {
426        assert!(matches!(
427            build_isin("US", "0378331é"),
428            Err(ConversionError::NotConvertible { .. })
429        ));
430        assert!(matches!(
431            build_isin("ÉS", "037833100"),
432            Err(ConversionError::NotConvertible { .. })
433        ));
434    }
435
436    // ─── ISIN ↔ CUSIP ────────────────────────────────────────────────────
437
438    #[test]
439    fn isin_to_cusip_apple() {
440        let isin = Isin::parse("US0378331005").unwrap();
441        assert_eq!(isin_to_cusip(&isin).unwrap().as_str(), "037833100");
442    }
443
444    #[test]
445    fn isin_to_cusip_accepts_canada() {
446        // A CA ISIN whose NSIN is a valid CUSIP converts cleanly.
447        let isin = cusip_to_isin(&Cusip::parse("037833100").unwrap(), "CA").unwrap();
448        assert_eq!(isin.country_code(), "CA");
449        assert_eq!(isin_to_cusip(&isin).unwrap().as_str(), "037833100");
450    }
451
452    #[test]
453    fn isin_to_cusip_rejects_non_us_ca() {
454        // A GB ISIN has no CUSIP, even though its NSIN is nine characters.
455        let isin = Isin::parse("GB0002634946").unwrap();
456        assert_eq!(
457            isin_to_cusip(&isin),
458            Err(ConversionError::UnsupportedCountry)
459        );
460        let de = Isin::parse("DE000BAY0017").unwrap();
461        assert_eq!(isin_to_cusip(&de), Err(ConversionError::UnsupportedCountry));
462    }
463
464    #[test]
465    fn cusip_to_isin_apple() {
466        let cusip = Cusip::parse("037833100").unwrap();
467        assert_eq!(
468            cusip_to_isin(&cusip, "US").unwrap().as_str(),
469            "US0378331005"
470        );
471    }
472
473    #[test]
474    fn cusip_to_isin_rejects_unknown_country() {
475        let cusip = Cusip::parse("037833100").unwrap();
476        assert_eq!(
477            cusip_to_isin(&cusip, "ZZ"),
478            Err(ConversionError::UnsupportedCountry)
479        );
480    }
481
482    #[test]
483    fn cusip_isin_round_trip() {
484        // isin_to_cusip then cusip_to_isin recovers the original ISIN.
485        for &s in &["US0378331005", "US5949181045"] {
486            let isin = Isin::parse(s).unwrap();
487            let cusip = isin_to_cusip(&isin).unwrap();
488            let back = cusip_to_isin(&cusip, isin.country_code()).unwrap();
489            assert_eq!(back, isin);
490        }
491        // And the reverse round-trip: CUSIP -> ISIN -> CUSIP.
492        for &c in &["037833100", "594918104", "38259P508"] {
493            let cusip = Cusip::parse(c).unwrap();
494            let isin = cusip_to_isin(&cusip, "US").unwrap();
495            assert_eq!(isin_to_cusip(&isin).unwrap(), cusip);
496        }
497    }
498
499    // ─── ISIN ↔ SEDOL ────────────────────────────────────────────────────
500
501    #[test]
502    fn isin_to_sedol_bae() {
503        let isin = Isin::parse("GB0002634946").unwrap();
504        assert_eq!(isin_to_sedol(&isin).unwrap().as_str(), "0263494");
505    }
506
507    #[test]
508    fn isin_to_sedol_accepts_ireland() {
509        let isin = sedol_to_isin(&Sedol::parse("0263494").unwrap(), "IE").unwrap();
510        assert_eq!(isin.country_code(), "IE");
511        assert_eq!(isin_to_sedol(&isin).unwrap().as_str(), "0263494");
512    }
513
514    #[test]
515    fn isin_to_sedol_rejects_non_gb_ie() {
516        let isin = Isin::parse("US0378331005").unwrap();
517        assert_eq!(
518            isin_to_sedol(&isin),
519            Err(ConversionError::UnsupportedCountry)
520        );
521    }
522
523    #[test]
524    fn isin_to_sedol_rejects_missing_00_padding() {
525        // A GB ISIN whose NSIN does not begin with "00" cannot embed a SEDOL.
526        // Build a valid GB ISIN with a non-"00" NSIN prefix.
527        let isin = build_isin("GB", "123456789").unwrap();
528        assert!(matches!(
529            isin_to_sedol(&isin),
530            Err(ConversionError::NotConvertible { .. })
531        ));
532    }
533
534    #[test]
535    fn isin_to_sedol_rejects_invalid_sedol_body() {
536        // A GB ISIN with "00" padding but a body that is not a valid SEDOL
537        // (a vowel is forbidden in a SEDOL) surfaces a validation error.
538        let isin = build_isin("GB", "00B0WNLA7").unwrap();
539        assert!(matches!(
540            isin_to_sedol(&isin),
541            Err(ConversionError::Validation(_))
542        ));
543    }
544
545    #[test]
546    fn sedol_to_isin_bae() {
547        let sedol = Sedol::parse("0263494").unwrap();
548        assert_eq!(
549            sedol_to_isin(&sedol, "GB").unwrap().as_str(),
550            "GB0002634946"
551        );
552    }
553
554    #[test]
555    fn sedol_to_isin_rejects_unknown_country() {
556        let sedol = Sedol::parse("0263494").unwrap();
557        assert_eq!(
558            sedol_to_isin(&sedol, "ZZ"),
559            Err(ConversionError::UnsupportedCountry)
560        );
561    }
562
563    #[test]
564    fn sedol_isin_round_trip() {
565        // isin_to_sedol then sedol_to_isin recovers the original ISIN.
566        let isin = Isin::parse("GB0002634946").unwrap();
567        let sedol = isin_to_sedol(&isin).unwrap();
568        let back = sedol_to_isin(&sedol, isin.country_code()).unwrap();
569        assert_eq!(back, isin);
570
571        // And SEDOL -> ISIN -> SEDOL for several SEDOLs.
572        for &s in &["0263494", "0540528", "B0WNLY7"] {
573            let sedol = Sedol::parse(s).unwrap();
574            let isin = sedol_to_isin(&sedol, "GB").unwrap();
575            assert_eq!(isin_to_sedol(&isin).unwrap(), sedol);
576        }
577    }
578
579    // ─── ISIN ↔ VALOR ────────────────────────────────────────────────────
580
581    #[test]
582    fn isin_to_valor_strips_leading_zeros() {
583        let isin = Isin::parse("CH0012138530").unwrap();
584        assert_eq!(isin_to_valor(&isin).unwrap().as_str(), "1213853");
585    }
586
587    #[test]
588    fn isin_to_valor_accepts_liechtenstein() {
589        let isin = valor_to_isin(&Valor::parse("1213853").unwrap(), "LI").unwrap();
590        assert_eq!(isin.country_code(), "LI");
591        assert_eq!(isin_to_valor(&isin).unwrap().as_str(), "1213853");
592    }
593
594    #[test]
595    fn isin_to_valor_all_zero_nsin_yields_zero() {
596        // An all-zero NSIN strips to the single VALOR digit "0", not "".
597        let isin = build_isin("CH", "000000000").unwrap();
598        let valor = isin_to_valor(&isin).unwrap();
599        assert_eq!(valor.as_str(), "0");
600        assert_eq!(valor.as_u64(), 0);
601    }
602
603    #[test]
604    fn isin_to_valor_rejects_non_ch_li() {
605        let isin = Isin::parse("US0378331005").unwrap();
606        assert_eq!(
607            isin_to_valor(&isin),
608            Err(ConversionError::UnsupportedCountry)
609        );
610    }
611
612    #[test]
613    fn isin_to_valor_rejects_non_numeric_nsin() {
614        // A CH ISIN whose NSIN contains a letter cannot embed a VALOR.
615        let isin = build_isin("CH", "00ABC1234").unwrap();
616        assert!(matches!(
617            isin_to_valor(&isin),
618            Err(ConversionError::Validation(_))
619        ));
620    }
621
622    #[test]
623    fn valor_to_isin_pads_to_nine_digits() {
624        let valor = Valor::parse("1213853").unwrap();
625        let isin = valor_to_isin(&valor, "CH").unwrap();
626        assert_eq!(isin.as_str(), "CH0012138530");
627        assert_eq!(isin.nsin(), "001213853");
628    }
629
630    #[test]
631    fn valor_to_isin_rejects_unknown_country() {
632        let valor = Valor::parse("1213853").unwrap();
633        assert_eq!(
634            valor_to_isin(&valor, "ZZ"),
635            Err(ConversionError::UnsupportedCountry)
636        );
637    }
638
639    #[test]
640    fn valor_isin_round_trip() {
641        // isin_to_valor then valor_to_isin recovers the original ISIN.
642        let isin = Isin::parse("CH0012138530").unwrap();
643        let valor = isin_to_valor(&isin).unwrap();
644        let back = valor_to_isin(&valor, isin.country_code()).unwrap();
645        assert_eq!(back, isin);
646
647        // And VALOR -> ISIN -> VALOR for several VALORs, including a
648        // nine-digit one that exactly fills the NSIN.
649        for &v in &["1213853", "908440", "24476758", "123456789", "7"] {
650            let valor = Valor::parse(v).unwrap();
651            let isin = valor_to_isin(&valor, "CH").unwrap();
652            assert_eq!(isin_to_valor(&isin).unwrap(), valor);
653        }
654    }
655
656    // ─── Cross-cutting ───────────────────────────────────────────────────
657
658    #[test]
659    fn check_digit_schemes_are_independent() {
660        // The CUSIP check digit ('0' for Apple) and the ISIN check digit
661        // ('5' for Apple) are computed by unrelated algorithms; the
662        // conversion recomputes the ISIN digit rather than reusing the
663        // CUSIP one.
664        let cusip = Cusip::parse("037833100").unwrap();
665        assert_eq!(cusip.check_digit(), '0');
666        let isin = cusip_to_isin(&cusip, "US").unwrap();
667        assert_eq!(isin.check_digit(), '5');
668    }
669
670    #[test]
671    fn conversion_error_carries_validation_source() {
672        // A failed inner validation is surfaced as ConversionError::Validation
673        // and never silently turned into a wrong answer.
674        let isin = build_isin("GB", "000000000").unwrap();
675        // NSIN "000000000" -> SEDOL body "0000000" has the wrong check digit
676        // unless it happens to be valid; assert the error is typed, whatever
677        // it is, rather than a wrong Sedol.
678        match isin_to_sedol(&isin) {
679            Ok(s) => {
680                // If it parses, it must be a genuine valid SEDOL.
681                assert!(Sedol::validate(s.as_str()).is_ok());
682            }
683            Err(ConversionError::Validation(ValidationError::BadCheckDigit { .. })) => {}
684            Err(other) => panic!("unexpected error: {other}"),
685        }
686    }
687
688    #[test]
689    fn every_built_isin_reparses() {
690        // Whatever the inputs, a successfully built ISIN always re-parses —
691        // the conversion never emits a structurally invalid identifier.
692        for &(country, nsin) in &[
693            ("US", "037833100"),
694            ("GB", "000263494"),
695            ("CH", "001213853"),
696            ("XS", "174878390"),
697        ] {
698            if let Ok(isin) = build_isin(country, nsin) {
699                assert!(Isin::validate(isin.as_str()).is_ok());
700            }
701        }
702    }
703}