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}