Skip to main content

regit_identifiers/
valor.rs

1// Copyright 2026 Regit.io — Nicolas Koenig
2// SPDX-License-Identifier: Apache-2.0
3
4//! VALOR — Valorennummer (SIX Financial Information).
5//!
6//! A VALOR is the Swiss national securities-identifying number. It is a
7//! purely numeric identifier of variable length, from 1 to 9 digits:
8//!
9//! ```text
10//!   1 2 1 3 8 5 3
11//!   └──────┬──────┘
12//!          └ 1 to 9 digits [0-9]   no internal structure, no check digit
13//! ```
14//!
15//! - The VALOR carries **no internal segments**: it is a single run of
16//!   between 1 and 9 ASCII decimal digits.
17//! - There is **no check digit** — validation is purely structural.
18//! - A Swiss ISIN embeds the VALOR: `CH` + the VALOR left-padded with zeros
19//!   to nine digits + the ISIN check digit. For example, VALOR `1213853`
20//!   becomes ISIN `CH0012138530`.
21//!
22//! [`Valor::parse`] enforces every rule: a length of 1 to 9 characters and a
23//! charset of ASCII digits only.
24//!
25//! # References
26//!
27//! - SIX Financial Information, *Valorennummer* — the Swiss national
28//!   securities-identification scheme.
29
30use crate::errors::ValidationError;
31
32/// A validated Valorennummer (SIX Financial Information).
33///
34/// A `Valor` can only be created by [`Valor::parse`] (or the explicitly
35/// unchecked [`Valor::from_bytes_unchecked`]), so a value of this type is a
36/// proof that it holds between 1 and 9 ASCII decimal digits. It stores the
37/// identifier inline as `[u8; 9]` plus a length, is `Copy`, and allocates
38/// nothing.
39///
40/// Because the VALOR is variable-length, the unused tail bytes of the array
41/// are always kept zeroed, so the derived `PartialEq`, `Eq`, and `Hash` are
42/// correct: two `Valor` values compare equal exactly when their digits do.
43///
44/// # Examples
45///
46/// ```
47/// use regit_identifiers::Valor;
48///
49/// let valor = Valor::parse("1213853").unwrap();
50/// assert_eq!(valor.as_str(), "1213853");
51/// assert_eq!(valor.len(), 7);
52/// assert_eq!(valor.as_u64(), 1_213_853);
53/// ```
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
55pub struct Valor {
56    /// The validated ASCII digit bytes, left-aligned; unused tail is zeroed.
57    bytes: [u8; Self::MAX_LENGTH],
58    /// The number of significant bytes in `bytes`, always `1..=9`.
59    len: u8,
60}
61
62impl Valor {
63    /// The minimum number of digits in a VALOR.
64    pub const MIN_LENGTH: usize = 1;
65
66    /// The maximum number of digits in a VALOR.
67    pub const MAX_LENGTH: usize = 9;
68
69    /// Parses and fully validates a VALOR.
70    ///
71    /// Validation is strict and, in order: the input must be 1 to 9
72    /// characters; every character must be an ASCII decimal digit. There is
73    /// no check digit.
74    ///
75    /// # Errors
76    ///
77    /// - [`ValidationError::Structure`] with rule `"VALOR must be 1 to 9
78    ///   digits"` if the input is empty or longer than nine characters.
79    /// - [`ValidationError::InvalidCharacter`] if a character is not an ASCII
80    ///   digit (this also rejects any non-ASCII character).
81    ///
82    /// # Examples
83    ///
84    /// ```
85    /// use regit_identifiers::Valor;
86    /// use regit_identifiers::errors::ValidationError;
87    ///
88    /// assert!(Valor::parse("1213853").is_ok());
89    ///
90    /// // A non-digit character is rejected, not silently accepted.
91    /// assert_eq!(
92    ///     Valor::parse("1213853A"),
93    ///     Err(ValidationError::InvalidCharacter { position: 8, found: 'A' }),
94    /// );
95    /// ```
96    pub fn parse(s: &str) -> Result<Self, ValidationError> {
97        // A VALOR is 1 to 9 characters; any other length is a structural
98        // violation rather than a per-position length mismatch.
99        let found = s.chars().count();
100        if !(Self::MIN_LENGTH..=Self::MAX_LENGTH).contains(&found) {
101            return Err(ValidationError::Structure {
102                rule: "VALOR must be 1 to 9 digits",
103            });
104        }
105        // Every character must be an ASCII digit. A non-ASCII character fails
106        // the predicate and is rejected here.
107        for (i, ch) in s.chars().enumerate() {
108            if !ch.is_ascii_digit() {
109                return Err(ValidationError::InvalidCharacter {
110                    position: i + 1,
111                    found: ch,
112                });
113            }
114        }
115        // Every character is ASCII, so the string is `found` ASCII bytes that
116        // fit the array; the unused tail stays zeroed.
117        let mut bytes = [0u8; Self::MAX_LENGTH];
118        if let Some(slot) = bytes.get_mut(..found) {
119            slot.copy_from_slice(s.as_bytes());
120        }
121        Ok(Self {
122            bytes,
123            len: u8::try_from(found).unwrap_or(0),
124        })
125    }
126
127    /// Validates a VALOR without constructing one.
128    ///
129    /// Equivalent to `Valor::parse(s).map(|_| ())`; use it when only the
130    /// verdict is needed.
131    ///
132    /// # Errors
133    ///
134    /// Returns the same [`ValidationError`] variants as [`Valor::parse`].
135    ///
136    /// # Examples
137    ///
138    /// ```
139    /// use regit_identifiers::Valor;
140    ///
141    /// assert!(Valor::validate("908440").is_ok());
142    /// assert!(Valor::validate("1234567890").is_err());
143    /// ```
144    pub fn validate(s: &str) -> Result<(), ValidationError> {
145        Self::parse(s).map(|_| ())
146    }
147
148    /// Wraps raw bytes as a `Valor` without any validation.
149    ///
150    /// The caller asserts that the first `len` bytes of `bytes` are ASCII
151    /// decimal digits, that `len` is in `1..=9`, and that the remaining tail
152    /// bytes are zero. This exists for reconstructing a `Valor` from bytes
153    /// that were validated earlier; prefer [`Valor::parse`] for any untrusted
154    /// input.
155    ///
156    /// # Examples
157    ///
158    /// ```
159    /// use regit_identifiers::Valor;
160    ///
161    /// let valor = Valor::from_bytes_unchecked(*b"908440\0\0\0", 6);
162    /// assert_eq!(valor.as_str(), "908440");
163    /// ```
164    #[must_use]
165    pub const fn from_bytes_unchecked(bytes: [u8; Self::MAX_LENGTH], len: u8) -> Self {
166        Self { bytes, len }
167    }
168
169    /// Returns the VALOR as a string slice.
170    ///
171    /// # Examples
172    ///
173    /// ```
174    /// use regit_identifiers::Valor;
175    ///
176    /// assert_eq!(Valor::parse("24476758").unwrap().as_str(), "24476758");
177    /// ```
178    #[must_use]
179    #[inline]
180    pub fn as_str(&self) -> &str {
181        core::str::from_utf8(self.as_bytes()).unwrap_or("")
182    }
183
184    /// Returns the VALOR as its raw ASCII digit bytes.
185    ///
186    /// The slice has [`Valor::len`] elements; the zeroed tail of the backing
187    /// array is not included.
188    ///
189    /// # Examples
190    ///
191    /// ```
192    /// use regit_identifiers::Valor;
193    ///
194    /// assert_eq!(Valor::parse("908440").unwrap().as_bytes(), b"908440");
195    /// ```
196    #[must_use]
197    #[inline]
198    pub fn as_bytes(&self) -> &[u8] {
199        self.bytes.get(..self.len as usize).unwrap_or(&[])
200    }
201
202    /// Returns the number of digits in the VALOR, always in `1..=9`.
203    ///
204    /// # Examples
205    ///
206    /// ```
207    /// use regit_identifiers::Valor;
208    ///
209    /// assert_eq!(Valor::parse("1213853").unwrap().len(), 7);
210    /// ```
211    #[must_use]
212    #[inline]
213    pub fn len(&self) -> usize {
214        self.len as usize
215    }
216
217    /// Always `false` — a valid VALOR is never empty (it has 1 to 9
218    /// digits). Provided for API consistency with [`Valor::len`].
219    ///
220    /// # Examples
221    ///
222    /// ```
223    /// use regit_identifiers::Valor;
224    ///
225    /// assert!(!Valor::parse("1213853").unwrap().is_empty());
226    /// ```
227    #[must_use]
228    #[inline]
229    pub fn is_empty(&self) -> bool {
230        self.len == 0
231    }
232
233    /// Returns the VALOR's numeric value.
234    ///
235    /// A VALOR is at most nine decimal digits, so its value always fits in a
236    /// `u64` (and indeed in a `u32`); leading zeros are absorbed.
237    ///
238    /// # Examples
239    ///
240    /// ```
241    /// use regit_identifiers::Valor;
242    ///
243    /// assert_eq!(Valor::parse("1213853").unwrap().as_u64(), 1_213_853);
244    /// assert_eq!(Valor::parse("000123").unwrap().as_u64(), 123);
245    /// ```
246    #[must_use]
247    #[inline]
248    pub fn as_u64(&self) -> u64 {
249        let mut value: u64 = 0;
250        for &b in self.as_bytes() {
251            value = value * 10 + u64::from(b.wrapping_sub(b'0'));
252        }
253        value
254    }
255
256    /// Returns the VALOR's numeric value as a `u32`.
257    ///
258    /// A nine-digit decimal fits comfortably in a `u32` (the maximum
259    /// `999_999_999` is well under `2^32 ≈ 4.29 × 10^9`); this is the
260    /// narrower companion of [`Valor::as_u64`].
261    ///
262    /// # Examples
263    ///
264    /// ```
265    /// use regit_identifiers::Valor;
266    ///
267    /// assert_eq!(Valor::parse("1213853").unwrap().as_u32(), 1_213_853);
268    /// assert_eq!(Valor::parse("999999999").unwrap().as_u32(), 999_999_999);
269    /// ```
270    #[must_use]
271    #[inline]
272    pub fn as_u32(&self) -> u32 {
273        // The maximum 9-digit decimal (999_999_999) is strictly below `2^32`,
274        // so the conversion can never truncate.
275        u32::try_from(self.as_u64()).unwrap_or(0)
276    }
277}
278
279impl core::fmt::Display for Valor {
280    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
281        f.write_str(self.as_str())
282    }
283}
284
285impl core::str::FromStr for Valor {
286    type Err = ValidationError;
287
288    fn from_str(s: &str) -> Result<Self, Self::Err> {
289        Self::parse(s)
290    }
291}
292
293impl AsRef<str> for Valor {
294    fn as_ref(&self) -> &str {
295        self.as_str()
296    }
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302    use crate::test_support::display;
303    use core::str::FromStr;
304
305    /// Real, well-known VALORs used as regression anchors.
306    const GOLDEN: &[&str] = &["1213853", "908440", "24476758"];
307
308    #[test]
309    fn parses_golden_valors() {
310        for &s in GOLDEN {
311            let valor = Valor::parse(s).unwrap_or_else(|e| panic!("{s} should parse: {e}"));
312            assert_eq!(valor.as_str(), s);
313        }
314    }
315
316    #[test]
317    fn accepts_minimum_and_maximum_length() {
318        let one = Valor::parse("7").unwrap();
319        assert_eq!(one.len(), 1);
320        assert_eq!(one.as_str(), "7");
321
322        let nine = Valor::parse("123456789").unwrap();
323        assert_eq!(nine.len(), 9);
324        assert_eq!(nine.as_str(), "123456789");
325        assert_eq!(Valor::MIN_LENGTH, 1);
326        assert_eq!(Valor::MAX_LENGTH, 9);
327    }
328
329    #[test]
330    fn accessors() {
331        let valor = Valor::parse("24476758").unwrap();
332        assert_eq!(valor.as_str(), "24476758");
333        assert_eq!(valor.as_bytes(), b"24476758");
334        assert_eq!(valor.len(), 8);
335        assert_eq!(valor.as_u64(), 24_476_758);
336    }
337
338    #[test]
339    fn as_u64_computes_numeric_value() {
340        assert_eq!(Valor::parse("1213853").unwrap().as_u64(), 1_213_853);
341        assert_eq!(Valor::parse("908440").unwrap().as_u64(), 908_440);
342        assert_eq!(Valor::parse("0").unwrap().as_u64(), 0);
343        // Leading zeros are absorbed into the numeric value.
344        assert_eq!(Valor::parse("000123").unwrap().as_u64(), 123);
345        // The largest possible VALOR still fits in a u64.
346        assert_eq!(Valor::parse("999999999").unwrap().as_u64(), 999_999_999);
347    }
348
349    #[test]
350    fn as_u32_agrees_with_as_u64() {
351        for s in ["0", "1213853", "908440", "999999999", "000123"] {
352            let v = Valor::parse(s).unwrap();
353            assert_eq!(u64::from(v.as_u32()), v.as_u64(), "value {s}");
354        }
355        assert_eq!(Valor::parse("999999999").unwrap().as_u32(), 999_999_999);
356    }
357
358    #[test]
359    fn rejects_empty_input() {
360        assert_eq!(
361            Valor::parse(""),
362            Err(ValidationError::Structure {
363                rule: "VALOR must be 1 to 9 digits",
364            })
365        );
366    }
367
368    #[test]
369    fn rejects_too_long() {
370        assert_eq!(
371            Valor::parse("1234567890"),
372            Err(ValidationError::Structure {
373                rule: "VALOR must be 1 to 9 digits",
374            })
375        );
376    }
377
378    #[test]
379    fn rejects_non_digit_character() {
380        assert_eq!(
381            Valor::parse("1213853A"),
382            Err(ValidationError::InvalidCharacter {
383                position: 8,
384                found: 'A',
385            })
386        );
387    }
388
389    #[test]
390    fn rejects_non_digit_at_first_position() {
391        assert!(matches!(
392            Valor::parse("X12345"),
393            Err(ValidationError::InvalidCharacter { position: 1, .. })
394        ));
395    }
396
397    #[test]
398    fn rejects_non_ascii_without_panic() {
399        // A multi-byte character must be rejected cleanly.
400        assert!(Valor::parse("12345é").is_err());
401        assert!(Valor::parse("é").is_err());
402    }
403
404    #[test]
405    fn validate_matches_parse() {
406        assert!(Valor::validate("908440").is_ok());
407        assert!(Valor::validate("").is_err());
408        assert!(Valor::validate("1234567890").is_err());
409    }
410
411    #[test]
412    fn round_trips_through_str() {
413        for &s in GOLDEN {
414            assert_eq!(Valor::parse(s).unwrap().as_str(), s);
415        }
416    }
417
418    #[test]
419    fn from_str_matches_parse() {
420        assert_eq!(Valor::from_str("1213853"), Valor::parse("1213853"));
421        assert!(Valor::from_str("nonsense").is_err());
422    }
423
424    #[test]
425    fn display_renders_identifier() {
426        let valor = Valor::parse("1213853").unwrap();
427        assert_eq!(display(valor).as_str(), "1213853");
428    }
429
430    #[test]
431    fn as_ref_str() {
432        let valor = Valor::parse("908440").unwrap();
433        let s: &str = valor.as_ref();
434        assert_eq!(s, "908440");
435    }
436
437    #[test]
438    fn from_bytes_unchecked_round_trip() {
439        let valor = Valor::from_bytes_unchecked(*b"908440\0\0\0", 6);
440        assert_eq!(valor, Valor::parse("908440").unwrap());
441    }
442
443    #[test]
444    fn unused_tail_is_zeroed_for_eq_and_hash() {
445        // Two values of differing length must never compare equal, and a
446        // value parsed twice must compare equal — the zeroed tail guarantees
447        // the derived PartialEq/Eq/Hash are correct.
448        let a = Valor::parse("12345").unwrap();
449        let b = Valor::parse("12345").unwrap();
450        assert_eq!(a, b);
451        assert_ne!(a, Valor::parse("123456").unwrap());
452        assert_ne!(a, Valor::parse("1234").unwrap());
453    }
454
455    #[test]
456    fn is_copy_and_eq_and_hashable() {
457        let a = Valor::parse("1213853").unwrap();
458        let b = a; // Copy
459        assert_eq!(a, b);
460        assert_ne!(a, Valor::parse("908440").unwrap());
461        // Usable as a map key (Eq + Hash) — checked by constructing a slice.
462        let keys = [a, b];
463        assert_eq!(keys[0], keys[1]);
464    }
465}